feat: implement Phase 3 container transformation with CLASSES.md compliance

- Add backend container transformation in engine.go following syntactic sugar specification
- Containers with .insertr get class removed and viable children get .insertr added
- Remove incorrect frontend container expansion - frontend only finds enhanced elements
- Fix StyleAwareEditor hasMultiPropertyElements runtime error
- Add addClass/removeClass methods to ContentEngine for class manipulation
- Update frontend to match HTML-first approach with no runtime container logic
- Test verified: container <section class='insertr'> transforms to individual h1.insertr, p.insertr, button.insertr

This completes the container expansion functionality per CLASSES.md:
Developer convenience (one .insertr enables section editing) + granular control (individual element editing)
This commit is contained in:
2025-09-21 19:17:12 +02:00
parent 4ef032cad6
commit b5e601d09f
16 changed files with 568 additions and 1407 deletions

View File

@@ -10,111 +10,23 @@ export class InsertrCore {
};
}
// Find all enhanced elements on the page with container expansion
// Find all enhanced elements on the page
// Note: Container expansion is handled at build-time by the backend enhancer
// Frontend should only find elements that already have .insertr class
findEnhancedElements() {
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;
return document.querySelectorAll('.insertr');
}
// 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 is viable for editing (allows simple formatting)
hasOnlyTextContent(element) {
// Allow elements with simple formatting tags
const allowedTags = new Set(['strong', 'b', 'em', 'i', 'a', 'span', 'code']);
for (const child of element.children) {
const tagName = child.tagName.toLowerCase();
// If child is not an allowed formatting tag, reject
if (!allowedTags.has(tagName)) {
return false;
}
// If formatting tag has nested complex elements, reject
if (child.children.length > 0) {
// Recursively check nested content isn't too complex
for (const nestedChild of child.children) {
const nestedTag = nestedChild.tagName.toLowerCase();
if (!allowedTags.has(nestedTag)) {
return false;
}
}
}
}
// Element has only text and/or simple formatting - 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());
}
// Note: All container expansion logic removed - handled by backend enhancer
// Frontend only works with elements that already have .insertr class
// Get element metadata
getElementMetadata(element) {
const existingId = element.getAttribute('data-content-id');
// Ensure element has insertr class for server processing
if (!element.classList.contains('insertr')) {
element.classList.add('insertr');
}
// Send HTML markup to server for unified ID generation
// HTML-first approach: no content type needed, just HTML markup for ID generation
return {
contentId: existingId, // null if new content, existing ID if updating
contentType: element.getAttribute('data-content-type') || this.detectContentType(element),
element: element,
htmlMarkup: element.outerHTML // Server will generate ID from this
};
@@ -130,38 +42,10 @@ export class InsertrCore {
return path.replace(/^\//, '');
}
// Detect content type for elements without data-content-type
detectContentType(element) {
const tag = element.tagName.toLowerCase();
// Only return database-valid types: 'text' or 'link'
if (tag === 'a' || tag === 'button') {
return 'link';
}
// All other elements are text content
return 'text';
}
// Get all elements with their metadata, including group elements
// Get all elements with their metadata
// Note: Container expansion handled by backend - frontend finds enhanced elements only
getAllElements() {
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));
const elements = this.findEnhancedElements();
return Array.from(elements).map(el => this.getElementMetadata(el));
}
}

View File

@@ -370,16 +370,16 @@ body:not(.insertr-edit-mode) .insertr-editing-hover::after {
transform: translateY(1px);
}
/* Style preview buttons - styled dynamically via JavaScript */
/* Style preview buttons - new approach using CSS isolation */
.insertr-style-btn.insertr-style-preview {
/* Preserve button structure */
background: var(--insertr-bg-primary) !important;
border: 1px solid var(--insertr-border-color) !important;
border-radius: var(--insertr-border-radius) !important;
padding: var(--insertr-spacing-xs) var(--insertr-spacing-sm) !important;
font-size: var(--insertr-font-size-sm) !important;
cursor: pointer !important;
transition: var(--insertr-transition) !important;
/* Clean button container - minimal styling */
background: var(--insertr-bg-primary);
border: 1px solid var(--insertr-border-color);
border-radius: var(--insertr-border-radius);
padding: 0; /* Remove padding - let preview content handle it */
font-size: var(--insertr-font-size-sm);
cursor: pointer;
transition: var(--insertr-transition);
/* Button layout */
min-height: 28px;
@@ -387,37 +387,59 @@ body:not(.insertr-edit-mode) .insertr-editing-hover::after {
align-items: center;
justify-content: center;
/* Ensure button remains clickable */
position: relative;
/* Reset any inherited text styles on the button container only */
font-family: var(--insertr-font-family);
text-decoration: none;
font-weight: normal;
text-transform: none;
/* Styles will be applied dynamically via JavaScript */
/* Don't set color here - let the preview content inherit naturally */
/* Ensure content fits */
overflow: hidden;
}
/* Add subtle background to preview buttons to ensure they remain clickable-looking */
.insertr-style-btn.insertr-style-preview::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--insertr-bg-primary);
opacity: 0.9;
border-radius: inherit;
z-index: -1;
/* Preview content container - minimal interference with original styling */
.insertr-preview-content {
/* Allow the original classes to style this element completely naturally */
display: inline-block;
/* Only set essential layout properties */
box-sizing: border-box;
/* Ensure text fits within button */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
/* Inherit font size from button container */
font-size: inherit;
/* Remove browser defaults that might interfere - but don't override intentional styling */
border: none;
margin: 0;
/* NO background, color, padding defaults - let the classes handle everything */
}
/* Hover state for preview buttons */
/* Minimal fallback styling when no meaningful classes are detected */
.insertr-preview-content.insertr-fallback-style {
padding: var(--insertr-spacing-xs) var(--insertr-spacing-sm);
color: var(--insertr-text-primary);
}
/* Hover state for preview buttons - subtle visual feedback */
.insertr-style-btn.insertr-style-preview:hover {
background: var(--insertr-bg-secondary) !important;
border-color: var(--insertr-text-muted) !important;
transform: none !important;
border-color: var(--insertr-text-muted);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Active state for preview buttons */
.insertr-style-btn.insertr-style-preview:active {
background: var(--insertr-border-color) !important;
transform: translateY(1px) !important;
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* Editor components */

View File

@@ -88,43 +88,41 @@ export class StyleAwareEditor {
styles: detection.styles,
structure: detection.structure,
strategy: editingStrategy,
hasMultiPropertyElements: this.hasMultiPropertyElements(detection.structure)
hasMultiPropertyElements: false // Removed - not needed for HTML-first approach
};
}
/**
* Determine the best editing strategy based on content complexity
* Determine the best editing interface based on element type
* HTML-first approach: detect behavior from element tag, not attributes
*
* @param {Object} detection - Style detection results
* @returns {string} - Editing strategy: 'simple', 'rich', 'multi-property'
* @returns {string} - Editing interface: 'direct' or 'rich'
*/
determineEditingStrategy(detection) {
if (detection.structure.length === 0) {
return 'simple'; // No nested elements
const tagName = this.element.tagName.toLowerCase();
// Multi-property elements get direct editing interface
if (this.isMultiPropertyElement(tagName)) {
return 'direct';
}
const hasStyledElements = detection.structure.some(piece => piece.type === 'styled');
if (hasStyledElements) {
return 'rich'; // Rich text with styling (handles all styled content including links)
} else {
return 'simple'; // Plain text
}
// All other elements get rich HTML editing with style preservation
return 'rich';
}
/**
* Check if element is a multi-property element requiring direct editing
*
* @param {string} tagName - Element tag name
* @returns {boolean} - True if multi-property element
*/
isMultiPropertyElement(tagName) {
const multiPropertyTags = new Set(['a', 'button', 'img']);
return multiPropertyTags.has(tagName);
}
/**
* Check if structure contains multi-property elements
*
* @param {Array} structure - Content structure array
* @returns {boolean} - True if has multi-property elements
*/
hasMultiPropertyElements(structure) {
return structure.some(piece =>
piece.type === 'styled' &&
piece.properties &&
Object.keys(piece.properties).length > 1
);
}
/**
* Create editor interface based on analysis
@@ -138,16 +136,16 @@ export class StyleAwareEditor {
// Create appropriate editor based on strategy
switch (analysis.strategy) {
case 'simple':
this.createSimpleEditor();
case 'direct':
this.createDirectEditor(analysis);
break;
case 'rich':
this.createRichEditor(analysis);
break;
}
// Add toolbar if enabled and we have any styled content
if (this.options.showToolbar && (analysis.styles.size > 0 || analysis.hasMultiPropertyElements)) {
// Add toolbar if enabled and we have styled content (rich editor only)
if (this.options.showToolbar && analysis.strategy === 'rich' && analysis.styles.size > 0) {
this.createStyleToolbar(analysis.styles, analysis.structure);
}
@@ -156,17 +154,114 @@ export class StyleAwareEditor {
}
/**
* Create simple text editor for plain content
* Create direct property editor for multi-property elements (links, buttons, images)
*/
createSimpleEditor() {
const textarea = document.createElement('textarea');
textarea.className = 'insertr-simple-editor';
textarea.value = this.originalContent.text;
textarea.rows = 3;
textarea.placeholder = 'Enter content...';
createDirectEditor(analysis) {
const tagName = this.element.tagName.toLowerCase();
this.contentEditor = textarea;
this.editorContainer.appendChild(textarea);
if (tagName === 'a') {
this.createLinkEditor();
} else if (tagName === 'button') {
this.createButtonEditor();
} else if (tagName === 'img') {
this.createImageEditor();
} else {
// Fallback to rich editor
this.createRichEditor(analysis);
}
}
/**
* Create link editor with URL and text fields
*/
createLinkEditor() {
const form = document.createElement('div');
form.className = 'insertr-direct-editor insertr-link-editor';
// Text field
const textGroup = document.createElement('div');
textGroup.className = 'insertr-form-group';
textGroup.innerHTML = `
<label class="insertr-form-label">Link Text</label>
<input type="text" class="insertr-form-input" id="link-text" value="${this.element.textContent}" placeholder="Link text">
`;
// URL field
const urlGroup = document.createElement('div');
urlGroup.className = 'insertr-form-group';
urlGroup.innerHTML = `
<label class="insertr-form-label">URL</label>
<input type="url" class="insertr-form-input" id="link-url" value="${this.element.href || ''}" placeholder="https://example.com">
`;
// Target field
const targetGroup = document.createElement('div');
targetGroup.className = 'insertr-form-group';
targetGroup.innerHTML = `
<label class="insertr-form-label">Target</label>
<select class="insertr-form-select" id="link-target">
<option value="">Same window</option>
<option value="_blank" ${this.element.target === '_blank' ? 'selected' : ''}>New window</option>
</select>
`;
form.appendChild(textGroup);
form.appendChild(urlGroup);
form.appendChild(targetGroup);
this.contentEditor = form;
this.editorContainer.appendChild(form);
}
/**
* Create button editor with text field
*/
createButtonEditor() {
const form = document.createElement('div');
form.className = 'insertr-direct-editor insertr-button-editor';
// Text field
const textGroup = document.createElement('div');
textGroup.className = 'insertr-form-group';
textGroup.innerHTML = `
<label class="insertr-form-label">Button Text</label>
<input type="text" class="insertr-form-input" id="button-text" value="${this.element.textContent}" placeholder="Button text">
`;
form.appendChild(textGroup);
this.contentEditor = form;
this.editorContainer.appendChild(form);
}
/**
* Create image editor with src and alt fields
*/
createImageEditor() {
const form = document.createElement('div');
form.className = 'insertr-direct-editor insertr-image-editor';
// Source field
const srcGroup = document.createElement('div');
srcGroup.className = 'insertr-form-group';
srcGroup.innerHTML = `
<label class="insertr-form-label">Image URL</label>
<input type="url" class="insertr-form-input" id="image-src" value="${this.element.src || ''}" placeholder="https://example.com/image.jpg">
`;
// Alt text field
const altGroup = document.createElement('div');
altGroup.className = 'insertr-form-group';
altGroup.innerHTML = `
<label class="insertr-form-label">Alt Text</label>
<input type="text" class="insertr-form-input" id="image-alt" value="${this.element.alt || ''}" placeholder="Image description">
`;
form.appendChild(srcGroup);
form.appendChild(altGroup);
this.contentEditor = form;
this.editorContainer.appendChild(form);
}
/**
@@ -242,46 +337,31 @@ export class StyleAwareEditor {
const button = document.createElement('button');
button.type = 'button';
button.className = 'insertr-style-btn';
button.textContent = styleInfo.name;
button.title = `Apply ${styleInfo.name} style`;
button.dataset.styleId = styleId;
// Apply preview styling by copying computed styles from the original element
// Create preview content container
const previewContent = document.createElement('span');
previewContent.className = 'insertr-preview-content';
previewContent.textContent = styleInfo.name;
// Apply the original classes to the preview content (not the button)
if (styleInfo.element && styleInfo.classes && styleInfo.classes.length > 0) {
// Add the detected classes first
styleInfo.classes.forEach(className => {
button.classList.add(className);
});
// Add special button class
// Add special preview button class to the button
button.classList.add('insertr-style-preview');
// Copy specific style properties from the original element to ensure they show
const computedStyle = window.getComputedStyle(styleInfo.element);
// Copy color (most important for visual preview)
if (computedStyle.color && computedStyle.color !== 'rgb(0, 0, 0)') {
button.style.setProperty('color', computedStyle.color, 'important');
}
// Copy font-weight
if (computedStyle.fontWeight && computedStyle.fontWeight !== '400') {
button.style.setProperty('font-weight', computedStyle.fontWeight, 'important');
}
// Copy text-decoration (for underlines, etc.)
if (computedStyle.textDecoration && computedStyle.textDecoration !== 'none') {
button.style.setProperty('text-decoration', computedStyle.textDecoration, 'important');
}
// Copy text-transform (for uppercase, etc.)
if (computedStyle.textTransform && computedStyle.textTransform !== 'none') {
button.style.setProperty('text-transform', computedStyle.textTransform, 'important');
}
// Don't copy background-color to keep button appearance
// Add the detected classes to the preview content
styleInfo.classes.forEach(className => {
previewContent.classList.add(className);
});
} else {
// No meaningful styles detected - use fallback
previewContent.classList.add('insertr-fallback-style');
}
// Add the preview content to the button
button.appendChild(previewContent);
// Add click handler for style application
button.addEventListener('click', (e) => {
e.preventDefault();
@@ -449,18 +529,16 @@ export class StyleAwareEditor {
/**
* Extract current content from editor
* HTML-first approach: always return HTML content
*
* @returns {Object} - Extracted content
*/
extractContent() {
if (this.contentEditor.className.includes('simple-editor')) {
// Simple text editor
return {
type: 'text',
content: this.contentEditor.value
};
if (this.contentEditor.className.includes('direct-editor')) {
// Direct property editor - extract form values and generate HTML
return this.extractDirectEditorContent();
} else if (this.contentEditor.className.includes('rich-editor')) {
// Rich text editor
// Rich text editor - return HTML as-is
return {
type: 'html',
content: this.contentEditor.innerHTML
@@ -469,40 +547,74 @@ export class StyleAwareEditor {
return null;
}
/**
* Extract content from direct property editors
*
* @returns {Object} - Content with generated HTML
*/
extractDirectEditorContent() {
const tagName = this.element.tagName.toLowerCase();
if (tagName === 'a') {
const text = document.getElementById('link-text').value;
const url = document.getElementById('link-url').value;
const target = document.getElementById('link-target').value;
return {
type: 'html',
content: text,
properties: { href: url, target: target }
};
} else if (tagName === 'button') {
const text = document.getElementById('button-text').value;
return {
type: 'html',
content: text
};
} else if (tagName === 'img') {
const src = document.getElementById('image-src').value;
const alt = document.getElementById('image-alt').value;
return {
type: 'html',
content: '',
properties: { src: src, alt: alt }
};
}
return null;
}
/**
* Apply extracted content to the original element
* HTML-first approach: always use HTML preservation with optional property updates
*
* @param {Object} content - Content to apply
* @returns {boolean} - Success status
*/
applyContentToElement(content) {
try {
switch (content.type) {
case 'text':
// Simple text - just update textContent
this.element.textContent = content.content;
return true;
case 'html':
// Rich HTML - use HTML preservation engine
return this.htmlEngine.applyFromEditing(this.element, content.content);
case 'structured':
// Structured content - reconstruct using style engine
const reconstructedHTML = this.styleEngine.reconstructHTML(
content.structure,
this.detectedStyles,
content.updatedProperties
);
return this.htmlEngine.applyFromEditing(this.element, reconstructedHTML);
default:
console.error('Unknown content type:', content.type);
return false;
// Apply properties if specified (for direct editors)
if (content.properties) {
for (const [property, value] of Object.entries(content.properties)) {
if (value) {
this.element.setAttribute(property, value);
} else {
this.element.removeAttribute(property);
}
}
}
// Apply HTML content using preservation engine
if (content.content !== undefined) {
return this.htmlEngine.applyFromEditing(this.element, content.content);
}
return true;
} catch (error) {
console.error('Failed to apply content:', error);
return false;