feat: implement container expansion and group editing functionality
- Add container element detection and child expansion in InsertrCore - Implement .insertr descendant expansion matching CLI behavior - Add .insertr-group collective editing with markdown editor - Fix UX issue where div.insertr got text input instead of proper child editors - Add comprehensive test cases for both features in about.html - Enable live preview for group editing with proper content splitting
This commit is contained in:
@@ -10,23 +10,137 @@ export class InsertrCore {
|
||||
};
|
||||
}
|
||||
|
||||
// Find all enhanced elements on the page
|
||||
// Find all enhanced elements on the page with container expansion
|
||||
findEnhancedElements() {
|
||||
return document.querySelectorAll('.insertr');
|
||||
const directElements = document.querySelectorAll('.insertr');
|
||||
const expandedElements = [];
|
||||
|
||||
directElements.forEach(element => {
|
||||
if (this.isContainer(element) && !element.classList.contains('insertr-group')) {
|
||||
// Container element (.insertr) - expand to viable children
|
||||
const children = this.findViableChildren(element);
|
||||
expandedElements.push(...children);
|
||||
} else {
|
||||
// Regular element or group (.insertr-group)
|
||||
expandedElements.push(element);
|
||||
}
|
||||
});
|
||||
|
||||
return expandedElements;
|
||||
}
|
||||
|
||||
// Check if element is a container that should expand to children
|
||||
isContainer(element) {
|
||||
const containerTags = new Set([
|
||||
'div', 'section', 'article', 'header',
|
||||
'footer', 'main', 'aside', 'nav'
|
||||
]);
|
||||
|
||||
return containerTags.has(element.tagName.toLowerCase());
|
||||
}
|
||||
|
||||
// Find viable children for editing (elements with only text content)
|
||||
findViableChildren(containerElement) {
|
||||
const viable = [];
|
||||
|
||||
for (const child of containerElement.children) {
|
||||
// Skip elements that already have .insertr class
|
||||
if (child.classList.contains('insertr')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip self-closing elements
|
||||
if (this.isSelfClosing(child)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if element has only text content (no nested HTML elements)
|
||||
if (this.hasOnlyTextContent(child)) {
|
||||
viable.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
return viable;
|
||||
}
|
||||
|
||||
// Check if element contains only text content (no nested HTML elements)
|
||||
hasOnlyTextContent(element) {
|
||||
for (const child of element.children) {
|
||||
// Found nested HTML element - not text-only
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only text nodes (and whitespace) - this is viable
|
||||
return element.textContent.trim().length > 0;
|
||||
}
|
||||
|
||||
// Check if element is self-closing
|
||||
isSelfClosing(element) {
|
||||
const selfClosingTags = new Set([
|
||||
'img', 'input', 'br', 'hr', 'meta', 'link',
|
||||
'area', 'base', 'col', 'embed', 'source', 'track', 'wbr'
|
||||
]);
|
||||
|
||||
return selfClosingTags.has(element.tagName.toLowerCase());
|
||||
}
|
||||
|
||||
// Get element metadata
|
||||
getElementMetadata(element) {
|
||||
return {
|
||||
contentId: element.getAttribute('data-content-id'),
|
||||
contentType: element.getAttribute('data-content-type'),
|
||||
contentId: element.getAttribute('data-content-id') || this.generateTempId(element),
|
||||
contentType: element.getAttribute('data-content-type') || this.detectContentType(element),
|
||||
element: element
|
||||
};
|
||||
}
|
||||
|
||||
// Get all elements with their metadata
|
||||
// Generate temporary ID for elements without data-content-id
|
||||
generateTempId(element) {
|
||||
const tag = element.tagName.toLowerCase();
|
||||
const text = element.textContent.trim().substring(0, 20).replace(/\s+/g, '-').toLowerCase();
|
||||
return `${tag}-${text}-${Date.now()}`;
|
||||
}
|
||||
|
||||
// Detect content type for elements without data-content-type
|
||||
detectContentType(element) {
|
||||
const tag = element.tagName.toLowerCase();
|
||||
|
||||
if (element.classList.contains('insertr-group')) {
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
switch (tag) {
|
||||
case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6':
|
||||
return 'text';
|
||||
case 'p':
|
||||
return 'textarea';
|
||||
case 'a': case 'button':
|
||||
return 'link';
|
||||
case 'div': case 'section':
|
||||
return 'markdown';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
// Get all elements with their metadata, including group elements
|
||||
getAllElements() {
|
||||
const elements = this.findEnhancedElements();
|
||||
return Array.from(elements).map(el => this.getElementMetadata(el));
|
||||
const directElements = document.querySelectorAll('.insertr, .insertr-group');
|
||||
const processedElements = [];
|
||||
|
||||
directElements.forEach(element => {
|
||||
if (element.classList.contains('insertr-group')) {
|
||||
// Group element - treat as single editable unit
|
||||
processedElements.push(element);
|
||||
} else if (this.isContainer(element)) {
|
||||
// Container element - expand to children
|
||||
const children = this.findViableChildren(element);
|
||||
processedElements.push(...children);
|
||||
} else {
|
||||
// Regular element
|
||||
processedElements.push(element);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(processedElements).map(el => this.getElementMetadata(el));
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,14 @@ class LivePreviewManager {
|
||||
|
||||
// Remove preview styling
|
||||
element.classList.remove('insertr-preview-active');
|
||||
|
||||
// For group elements, also clear preview from children
|
||||
if (element.classList.contains('insertr-group')) {
|
||||
Array.from(element.children).forEach(child => {
|
||||
child.classList.remove('insertr-preview-active');
|
||||
});
|
||||
}
|
||||
|
||||
this.activeElement = null;
|
||||
this.originalContent = null;
|
||||
}
|
||||
@@ -122,7 +130,15 @@ class LivePreviewManager {
|
||||
restoreOriginalContent(element) {
|
||||
if (!this.originalContent) return;
|
||||
|
||||
if (typeof this.originalContent === 'object') {
|
||||
if (Array.isArray(this.originalContent)) {
|
||||
// Group element - restore children content
|
||||
const children = Array.from(element.children);
|
||||
children.forEach((child, index) => {
|
||||
if (this.originalContent[index]) {
|
||||
child.textContent = this.originalContent[index];
|
||||
}
|
||||
});
|
||||
} else if (typeof this.originalContent === 'object') {
|
||||
// Link element
|
||||
element.textContent = this.originalContent.text;
|
||||
if (this.originalContent.url) {
|
||||
@@ -201,6 +217,12 @@ export class InsertrFormRenderer {
|
||||
this.closeForm();
|
||||
|
||||
const { element, contentId, contentType } = meta;
|
||||
|
||||
// Check if this is a group element
|
||||
if (element.classList.contains('insertr-group')) {
|
||||
return this.showGroupEditForm(element, onSave, onCancel);
|
||||
}
|
||||
|
||||
const config = this.getFieldConfig(element, contentType);
|
||||
|
||||
// Initialize preview manager for this element
|
||||
@@ -236,6 +258,196 @@ export class InsertrFormRenderer {
|
||||
return overlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and show group edit form for .insertr-group elements
|
||||
* @param {HTMLElement} groupElement - The .insertr-group container element
|
||||
* @param {Function} onSave - Save callback
|
||||
* @param {Function} onCancel - Cancel callback
|
||||
*/
|
||||
showGroupEditForm(groupElement, onSave, onCancel) {
|
||||
// Extract content from all child elements
|
||||
const children = this.getGroupChildren(groupElement);
|
||||
const combinedContent = this.combineChildContent(children);
|
||||
|
||||
// Create group-specific config
|
||||
const config = {
|
||||
type: 'markdown',
|
||||
label: 'Group Content (Markdown)',
|
||||
rows: Math.max(8, children.length * 2),
|
||||
placeholder: 'Edit all content together using Markdown...'
|
||||
};
|
||||
|
||||
// Initialize preview manager for the group
|
||||
this.previewManager.setActiveElement(groupElement);
|
||||
|
||||
// Create form
|
||||
const form = this.createEditForm('group-edit', config, combinedContent);
|
||||
|
||||
// Create overlay with backdrop
|
||||
const overlay = this.createOverlay(form);
|
||||
|
||||
// Position form with enhanced sizing
|
||||
this.positionForm(groupElement, overlay);
|
||||
|
||||
// Set up height change callback
|
||||
this.previewManager.setHeightChangeCallback((changedElement) => {
|
||||
this.repositionModal(changedElement, overlay);
|
||||
});
|
||||
|
||||
// Setup group-specific event handlers
|
||||
this.setupGroupFormHandlers(form, overlay, groupElement, children, { onSave, onCancel });
|
||||
|
||||
// Show form
|
||||
document.body.appendChild(overlay);
|
||||
this.currentOverlay = overlay;
|
||||
|
||||
// Focus the textarea
|
||||
const textarea = form.querySelector('textarea');
|
||||
if (textarea) {
|
||||
setTimeout(() => textarea.focus(), 100);
|
||||
}
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get viable children from group element
|
||||
*/
|
||||
getGroupChildren(groupElement) {
|
||||
const children = [];
|
||||
for (const child of groupElement.children) {
|
||||
// Skip elements that don't have text content
|
||||
if (child.textContent.trim().length > 0) {
|
||||
children.push(child);
|
||||
}
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine content from multiple child elements into markdown
|
||||
*/
|
||||
combineChildContent(children) {
|
||||
const parts = [];
|
||||
|
||||
children.forEach(child => {
|
||||
const content = child.textContent.trim();
|
||||
if (content) {
|
||||
parts.push(content);
|
||||
}
|
||||
});
|
||||
|
||||
// Join with double newlines to create paragraph separation in markdown
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Split markdown content back into individual element content
|
||||
*/
|
||||
splitMarkdownContent(markdown, children) {
|
||||
// Split on double newlines to get paragraphs
|
||||
const paragraphs = markdown.split(/\n\s*\n/).filter(p => p.trim());
|
||||
const results = [];
|
||||
|
||||
// Map paragraphs back to children
|
||||
children.forEach((child, index) => {
|
||||
const content = paragraphs[index] || '';
|
||||
results.push({
|
||||
element: child,
|
||||
content: content.trim()
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers for group editing
|
||||
*/
|
||||
setupGroupFormHandlers(form, overlay, groupElement, children, { onSave, onCancel }) {
|
||||
const saveBtn = form.querySelector('.insertr-btn-save');
|
||||
const cancelBtn = form.querySelector('.insertr-btn-cancel');
|
||||
|
||||
// Setup live preview for markdown content
|
||||
const textarea = form.querySelector('textarea');
|
||||
if (textarea) {
|
||||
textarea.addEventListener('input', () => {
|
||||
const markdown = textarea.value;
|
||||
this.previewGroupContent(groupElement, children, markdown);
|
||||
});
|
||||
}
|
||||
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', () => {
|
||||
const markdown = textarea.value;
|
||||
const splitContent = this.splitMarkdownContent(markdown, children);
|
||||
|
||||
// Clear preview before saving
|
||||
this.previewManager.clearPreview(groupElement);
|
||||
|
||||
// Update each child element
|
||||
splitContent.forEach(({ element, content }) => {
|
||||
if (content) {
|
||||
element.textContent = content;
|
||||
}
|
||||
});
|
||||
|
||||
onSave({ text: markdown });
|
||||
this.closeForm();
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
this.previewManager.clearPreview(groupElement);
|
||||
onCancel();
|
||||
this.closeForm();
|
||||
});
|
||||
}
|
||||
|
||||
// ESC key to cancel
|
||||
const keyHandler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.previewManager.clearPreview(groupElement);
|
||||
onCancel();
|
||||
this.closeForm();
|
||||
document.removeEventListener('keydown', keyHandler);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', keyHandler);
|
||||
|
||||
// Click outside to cancel
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) {
|
||||
this.previewManager.clearPreview(groupElement);
|
||||
onCancel();
|
||||
this.closeForm();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview group content changes
|
||||
*/
|
||||
previewGroupContent(groupElement, children, markdown) {
|
||||
// Store original content if first preview
|
||||
if (!this.previewManager.originalContent && this.previewManager.activeElement === groupElement) {
|
||||
this.previewManager.originalContent = children.map(child => child.textContent);
|
||||
}
|
||||
|
||||
// Apply preview styling to group
|
||||
groupElement.classList.add('insertr-preview-active');
|
||||
|
||||
// Split and preview content
|
||||
const splitContent = this.splitMarkdownContent(markdown, children);
|
||||
splitContent.forEach(({ element, content }) => {
|
||||
if (content) {
|
||||
element.textContent = content;
|
||||
element.classList.add('insertr-preview-active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close current form
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user