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:
2025-09-07 20:43:43 +02:00
parent 53762645e0
commit fdf9e1bb7e
3 changed files with 363 additions and 10 deletions

View File

@@ -33,7 +33,7 @@
<section class="services"> <section class="services">
<div class="container"> <div class="container">
<h2 class="insertr">Our Story</h2> <h2 class="insertr">Our Story</h2>
<div class="insertr"> <div class="insertr-group">
<p>Founded in 2020, Acme Consulting emerged from a simple observation: small businesses needed access to the same high-quality strategic advice that large corporations receive, but in a format that was accessible, affordable, and actionable.</p> <p>Founded in 2020, Acme Consulting emerged from a simple observation: small businesses needed access to the same high-quality strategic advice that large corporations receive, but in a format that was accessible, affordable, and actionable.</p>
<p>Our founders, with combined experience of over 30 years in business strategy, operations, and technology, recognized that the traditional consulting model wasn't serving the needs of growing businesses. We set out to change that.</p> <p>Our founders, with combined experience of over 30 years in business strategy, operations, and technology, recognized that the traditional consulting model wasn't serving the needs of growing businesses. We set out to change that.</p>
@@ -96,6 +96,33 @@
</div> </div>
</section> </section>
<!-- Test Section for Insertr Features -->
<section class="testimonial">
<div class="container">
<h2 class="insertr">Feature Tests</h2>
<!-- Test 1: .insertr container expansion (should make each p individually editable) -->
<div style="margin-bottom: 2rem;">
<h3>Test 1: Container Expansion (.insertr)</h3>
<div class="insertr" style="border: 2px dashed #ccc; padding: 1rem;">
<p>This paragraph should be individually editable with a textarea.</p>
<p>This second paragraph should also be individually editable.</p>
<p>Each paragraph should get its own modal when clicked.</p>
</div>
</div>
<!-- Test 2: .insertr-group collective editing (should edit all together) -->
<div>
<h3>Test 2: Group Editing (.insertr-group)</h3>
<div class="insertr-group" style="border: 2px solid #007cba; padding: 1rem;">
<p>This paragraph is part of a group.</p>
<p>Clicking anywhere in the group should open one markdown editor.</p>
<p>All content should be editable together as markdown.</p>
</div>
</div>
</div>
</section>
<!-- Footer --> <!-- Footer -->
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">

View File

@@ -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() { 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 // Get element metadata
getElementMetadata(element) { getElementMetadata(element) {
return { return {
contentId: element.getAttribute('data-content-id'), contentId: element.getAttribute('data-content-id') || this.generateTempId(element),
contentType: element.getAttribute('data-content-type'), contentType: element.getAttribute('data-content-type') || this.detectContentType(element),
element: 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() { getAllElements() {
const elements = this.findEnhancedElements(); const directElements = document.querySelectorAll('.insertr, .insertr-group');
return Array.from(elements).map(el => this.getElementMetadata(el)); 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));
} }
} }

View File

@@ -115,6 +115,14 @@ class LivePreviewManager {
// Remove preview styling // Remove preview styling
element.classList.remove('insertr-preview-active'); 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.activeElement = null;
this.originalContent = null; this.originalContent = null;
} }
@@ -122,7 +130,15 @@ class LivePreviewManager {
restoreOriginalContent(element) { restoreOriginalContent(element) {
if (!this.originalContent) return; 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 // Link element
element.textContent = this.originalContent.text; element.textContent = this.originalContent.text;
if (this.originalContent.url) { if (this.originalContent.url) {
@@ -201,6 +217,12 @@ export class InsertrFormRenderer {
this.closeForm(); this.closeForm();
const { element, contentId, contentType } = meta; 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); const config = this.getFieldConfig(element, contentType);
// Initialize preview manager for this element // Initialize preview manager for this element
@@ -236,6 +258,196 @@ export class InsertrFormRenderer {
return overlay; 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 * Close current form
*/ */