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:
@@ -33,7 +33,7 @@
|
||||
<section class="services">
|
||||
<div class="container">
|
||||
<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>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>
|
||||
</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 class="footer">
|
||||
<div class="container">
|
||||
|
||||
@@ -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