/** * StyleAwareEditor - Rich text editor with style-specific formatting options * * Implements the sophisticated style preservation system described in CLASSES.md: * - Automatic style detection from nested elements * - Formatting toolbar with developer-style-based options * - Multi-property editing for complex elements (links, images, etc.) * - Perfect attribute preservation during editing */ import { styleDetectionEngine } from '../utils/style-detection.js'; import { htmlPreservationEngine } from '../utils/html-preservation.js'; export class StyleAwareEditor { constructor(element, options = {}) { this.element = element; this.options = { showToolbar: true, enableMultiProperty: true, preserveAttributes: true, ...options }; // Core system components this.styleEngine = styleDetectionEngine; this.htmlEngine = htmlPreservationEngine; // Editor state this.isInitialized = false; this.editorContainer = null; this.contentEditor = null; this.toolbar = null; this.detectedStyles = null; this.contentStructure = null; this.originalContent = null; // Event callbacks this.onSave = options.onSave || (() => { }); this.onCancel = options.onCancel || (() => { }); this.onChange = options.onChange || (() => { }); } /** * Initialize the style-aware editor * Analyzes element, detects styles, creates appropriate editing interface */ async initialize() { if (this.isInitialized) { return; } try { // Analyze element for styles and structure const analysis = this.analyzeElement(); // Create editor interface based on analysis this.createEditorInterface(analysis); // Set up event handlers this.setupEventHandlers(); this.isInitialized = true; } catch (error) { console.error('Failed to initialize StyleAwareEditor:', error); throw error; } } /** * Analyze element to determine editing strategy * * @returns {Object} - Analysis results with styles, structure, and editing strategy */ analyzeElement() { // Extract content with preservation metadata this.originalContent = this.htmlEngine.extractForEditing(this.element); // Detect styles and structure const detection = this.styleEngine.detectStylesAndStructure(this.element); this.detectedStyles = detection.styles; this.contentStructure = detection.structure; // Determine editing strategy based on complexity const editingStrategy = this.determineEditingStrategy(detection); return { styles: detection.styles, structure: detection.structure, strategy: editingStrategy, hasMultiPropertyElements: false // Removed - not needed for HTML-first approach }; } /** * 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 interface: 'direct' or 'rich' */ determineEditingStrategy(detection) { const tagName = this.element.tagName.toLowerCase(); // Multi-property elements get direct editing interface if (this.isMultiPropertyElement(tagName)) { return 'direct'; } // 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); } /** * Create editor interface based on analysis * * @param {Object} analysis - Analysis results */ createEditorInterface(analysis) { // Create main editor container this.editorContainer = document.createElement('div'); this.editorContainer.className = 'insertr-style-aware-editor'; // Create appropriate editor based on strategy switch (analysis.strategy) { case 'direct': this.createDirectEditor(analysis); break; case 'rich': this.createRichEditor(analysis); break; } // Add toolbar if enabled and we have any formatting options (rich editor only) // This includes both detected styles and smart defaults if (this.options.showToolbar && analysis.strategy === 'rich' && analysis.styles.size > 0) { this.createStyleToolbar(analysis.styles, analysis.structure); } // Add form actions this.createFormActions(); } /** * Create direct property editor for multi-property elements (links, buttons, images) */ createDirectEditor(analysis) { const tagName = this.element.tagName.toLowerCase(); 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 unified link configuration component * * @param {Object} options - Configuration options * @param {string} options.text - Link text (optional) * @param {string} options.url - Link URL (optional) * @param {string} options.target - Link target (optional) * @param {boolean} options.showText - Whether to show text field * @param {string} options.title - Form title * @param {Function} options.onSave - Save callback * @param {Function} options.onCancel - Cancel callback * @param {Function} options.onRemove - Remove callback (optional) * @returns {HTMLElement} - Link configuration form */ createLinkConfiguration(options = {}) { const { text = '', url = '', target = '', showText = true, title = 'Configure Link', onSave = () => {}, onCancel = () => {}, onRemove = null } = options; // Create form container const form = document.createElement('div'); form.className = 'insertr-direct-editor insertr-link-editor'; // Create title const titleElement = document.createElement('h3'); titleElement.className = 'insertr-editor-title'; titleElement.textContent = title; titleElement.style.cssText = ` margin: 0 0 ${getComputedStyle(document.documentElement).getPropertyValue('--insertr-spacing-md') || '16px'} 0; font-size: 18px; font-weight: 600; color: var(--insertr-text-primary); `; form.appendChild(titleElement); // Text field (if needed) if (showText) { const textGroup = document.createElement('div'); textGroup.className = 'insertr-form-group'; textGroup.innerHTML = ` `; form.appendChild(textGroup); } // URL field with validation const urlGroup = document.createElement('div'); urlGroup.className = 'insertr-form-group'; urlGroup.innerHTML = ` `; form.appendChild(urlGroup); // Target field const targetGroup = document.createElement('div'); targetGroup.className = 'insertr-form-group'; targetGroup.innerHTML = `
Choose how the link opens when clicked
`; form.appendChild(targetGroup); // Add real-time validation this.addLinkValidation(form); // Add save/cancel handlers form.addEventListener('submit', (e) => { e.preventDefault(); const formData = this.extractLinkFormData(form); if (formData) { onSave(formData); } }); // Store callbacks for later use form._onSave = () => { const formData = this.extractLinkFormData(form); if (formData) { onSave(formData); } }; form._onCancel = onCancel; form._onRemove = onRemove; return form; } /** * Extract form data from link configuration form */ extractLinkFormData(form) { const textInput = form.querySelector('#link-text'); const urlInput = form.querySelector('#link-url'); const targetSelect = form.querySelector('#link-target'); const text = textInput ? textInput.value.trim() : ''; const url = urlInput ? urlInput.value.trim() : ''; const target = targetSelect ? targetSelect.value : ''; // Validation if (textInput && !text) { alert('Please enter link text'); textInput.focus(); return null; } if (!url) { alert('Please enter a valid URL'); urlInput.focus(); return null; } return { text, url, target }; } /** * Create link editor with URL and text fields */ createLinkEditor() { const linkConfig = this.createLinkConfiguration({ text: this.element.textContent, url: this.element.href || '', target: this.element.target || '', showText: true, title: 'Edit Link', onSave: (data) => { // Data will be extracted in extractDirectEditorContent } }); this.contentEditor = linkConfig; this.editorContainer.appendChild(linkConfig); // Focus the first input setTimeout(() => { const textInput = linkConfig.querySelector('#link-text'); if (textInput) { textInput.focus(); textInput.select(); } }, 100); } /** * Create button editor with text field */ createButtonEditor() { const form = document.createElement('div'); form.className = 'insertr-direct-editor insertr-button-editor'; // Create a title for the editor const title = document.createElement('h3'); title.className = 'insertr-editor-title'; title.textContent = 'Edit Button'; title.style.cssText = ` margin: 0 0 ${getComputedStyle(document.documentElement).getPropertyValue('--insertr-spacing-md') || '16px'} 0; font-size: 18px; font-weight: 600; color: var(--insertr-text-primary); `; form.appendChild(title); // Text field const textGroup = document.createElement('div'); textGroup.className = 'insertr-form-group'; textGroup.innerHTML = `
This text will appear on the button
`; form.appendChild(textGroup); this.contentEditor = form; this.editorContainer.appendChild(form); // Focus the input setTimeout(() => { const textInput = form.querySelector('#button-text'); if (textInput) { textInput.focus(); textInput.select(); } }, 100); } /** * Create image editor with src and alt fields */ createImageEditor() { const form = document.createElement('div'); form.className = 'insertr-direct-editor insertr-image-editor'; // Create a title for the editor const title = document.createElement('h3'); title.className = 'insertr-editor-title'; title.textContent = 'Edit Image'; title.style.cssText = ` margin: 0 0 ${getComputedStyle(document.documentElement).getPropertyValue('--insertr-spacing-md') || '16px'} 0; font-size: 18px; font-weight: 600; color: var(--insertr-text-primary); `; form.appendChild(title); // Source field const srcGroup = document.createElement('div'); srcGroup.className = 'insertr-form-group'; srcGroup.innerHTML = `
Enter the full URL to the image file
`; // Alt text field const altGroup = document.createElement('div'); altGroup.className = 'insertr-form-group'; altGroup.innerHTML = `
Describe the image for screen readers and accessibility
`; form.appendChild(srcGroup); form.appendChild(altGroup); // Add image preview if src exists if (this.element.src) { const previewGroup = document.createElement('div'); previewGroup.className = 'insertr-form-group'; previewGroup.innerHTML = ` ${this.escapeHtml(this.element.alt || '')} `; form.appendChild(previewGroup); } this.contentEditor = form; this.editorContainer.appendChild(form); // Focus the first input setTimeout(() => { const srcInput = form.querySelector('#image-src'); if (srcInput) { srcInput.focus(); srcInput.select(); } }, 100); } /** * Create rich text editor with style preservation * * @param {Object} analysis - Analysis results */ createRichEditor(analysis) { // Create contentEditable div const editor = document.createElement('div'); editor.className = 'insertr-rich-editor'; editor.contentEditable = true; editor.innerHTML = this.originalContent.html; this.contentEditor = editor; this.editorContainer.appendChild(editor); } /** * Create formatting toolbar with detected style buttons and link button * * @param {Map} styles - Detected styles * @param {Array} structure - Content structure (to detect links) */ createStyleToolbar(styles, structure = []) { const toolbar = document.createElement('div'); toolbar.className = 'insertr-style-toolbar'; const title = document.createElement('span'); title.textContent = 'Formatting:'; title.className = 'insertr-toolbar-title'; toolbar.appendChild(title); // Store references to style buttons for state updates this.styleButtons = new Map(); // Add button for each detected style (except links - those use the link popup) for (const [styleId, styleInfo] of styles) { // Skip link styles - they should be handled by the link popup, not toolbar buttons if (styleInfo.tagName.toLowerCase() === 'a') { continue; } const button = this.createStyleButton(styleId, styleInfo); toolbar.appendChild(button); this.styleButtons.set(styleId, button); } // Add link button if we have links in content or always for rich editor const hasLinks = structure.some(piece => piece.type === 'styled' && piece.element && piece.element.tagName.toLowerCase() === 'a' ); if (hasLinks || styles.size > 0) { const linkButton = this.createLinkButton(); toolbar.appendChild(linkButton); } this.toolbar = toolbar; this.editorContainer.insertBefore(toolbar, this.contentEditor); // Set up selection change listener to update button states this.setupSelectionChangeListener(); } /** * Set up listener for selection changes to update button states */ setupSelectionChangeListener() { if (!this.contentEditor || !this.styleButtons) { return; } // Listen for selection changes const updateButtonStates = () => { this.updateStyleButtonStates(); }; // Multiple events can trigger selection changes document.addEventListener('selectionchange', updateButtonStates); this.contentEditor.addEventListener('keyup', updateButtonStates); this.contentEditor.addEventListener('mouseup', updateButtonStates); this.contentEditor.addEventListener('input', updateButtonStates); // Store cleanup function this._selectionChangeCleanup = () => { document.removeEventListener('selectionchange', updateButtonStates); this.contentEditor.removeEventListener('keyup', updateButtonStates); this.contentEditor.removeEventListener('mouseup', updateButtonStates); this.contentEditor.removeEventListener('input', updateButtonStates); }; } /** * Update style button states based on current selection */ updateStyleButtonStates() { if (!this.styleButtons || !this.detectedStyles) { return; } const selection = window.getSelection(); if (selection.rangeCount === 0) { // No selection - reset all buttons to normal state this.styleButtons.forEach(button => { button.classList.remove('insertr-style-active'); }); return; } const range = selection.getRangeAt(0); // Check each style button for (const [styleId, button] of this.styleButtons) { const styleInfo = this.detectedStyles.get(styleId); if (!styleInfo) continue; const analysis = this.analyzeSelectionFormatting(range, styleInfo); // Update button state based on analysis if (analysis.coverage > 0.5) { button.classList.add('insertr-style-active'); button.title = `Remove ${styleInfo.name} formatting`; } else { button.classList.remove('insertr-style-active'); button.title = `Apply ${styleInfo.name} formatting`; } } } /** * Create unified formatting button * * @param {Object} config - Button configuration * @param {string} config.type - Button type: 'style', 'link', 'action' * @param {string} config.title - Button title/tooltip * @param {string} config.text - Button preview text * @param {Function} config.onClick - Click handler * @param {Object} config.styleInfo - Style information (for style buttons) * @param {string} config.styleId - Style ID (for style buttons) * @returns {HTMLElement} - Formatted button */ createFormattingButton(config) { const { type = 'action', title = '', text = '', onClick = () => {}, styleInfo = null, styleId = null } = config; // Create button element const button = document.createElement('button'); button.type = 'button'; button.className = 'insertr-style-btn'; button.title = title; if (styleId) { button.dataset.styleId = styleId; } // Create three-layer structure for style isolation const buttonFrame = document.createElement('span'); buttonFrame.className = 'insertr-button-frame'; const styleSample = document.createElement('span'); styleSample.className = 'insertr-style-sample'; styleSample.textContent = text; // Apply type-specific styling switch (type) { case 'style': this.applyStyleButtonStyling(button, styleSample, styleInfo); break; case 'link': this.applyLinkButtonStyling(button, styleSample); break; case 'action': default: // Default action button styling - add fallback class styleSample.classList.add('insertr-fallback-style'); break; } // Build three-layer structure: button → frame → sample buttonFrame.appendChild(styleSample); button.appendChild(buttonFrame); // Add click handler button.addEventListener('click', (e) => { e.preventDefault(); onClick(e); }); return button; } /** * Apply styling for style buttons */ applyStyleButtonStyling(button, styleSample, styleInfo) { if (!styleInfo) return; // ALL buttons use the same unified three-layer architecture button.classList.add('insertr-style-preview'); // Apply appropriate preview styling to the isolated sample if (styleInfo.isDefault) { // Default semantic formatting - apply styling to sample if (styleInfo.tagName === 'strong') { styleSample.style.fontWeight = 'bold'; } else if (styleInfo.tagName === 'em') { styleSample.style.fontStyle = 'italic'; } else if (styleInfo.tagName === 'a') { styleSample.style.textDecoration = 'underline'; styleSample.style.color = '#0066cc'; } // Add helpful description to title button.title = `${styleInfo.name}: ${styleInfo.description || 'Default formatting option'}`; } else if (styleInfo.element && styleInfo.classes && styleInfo.classes.length > 0) { // Detected style - apply original classes to style sample (isolated preview) styleInfo.classes.forEach(className => { styleSample.classList.add(className); }); button.title = `Apply ${styleInfo.classes.join(' ')} styling`; } else { // No meaningful styles detected - use fallback styleSample.classList.add('insertr-fallback-style'); } } /** * Apply styling for link buttons */ applyLinkButtonStyling(button, styleSample) { // Add link-specific styling to the isolated sample styleSample.style.textDecoration = 'underline'; styleSample.style.color = '#0066cc'; button.title = 'Create hyperlink'; } /** * Create button for applying detected style * * @param {string} styleId - Style identifier * @param {Object} styleInfo - Style information * @returns {HTMLElement} - Style button */ createStyleButton(styleId, styleInfo) { return this.createFormattingButton({ type: 'style', title: `Apply ${styleInfo.name} style`, text: styleInfo.name, onClick: () => this.applyStyle(styleId), styleInfo: styleInfo, styleId: styleId }); } /** * Create link button for opening link configuration popup * * @returns {HTMLElement} - Link button */ createLinkButton() { return this.createFormattingButton({ type: 'link', title: 'Add/Edit Link', text: '🔗 Link', onClick: () => this.openLinkPopup() }); } /** * Create form action buttons (Save, Cancel) */ createFormActions() { const actions = document.createElement('div'); actions.className = 'insertr-form-actions'; const saveBtn = document.createElement('button'); saveBtn.type = 'button'; saveBtn.className = 'insertr-btn-save'; saveBtn.textContent = 'Save'; const cancelBtn = document.createElement('button'); cancelBtn.type = 'button'; cancelBtn.className = 'insertr-btn-cancel'; cancelBtn.textContent = 'Cancel'; actions.appendChild(saveBtn); actions.appendChild(cancelBtn); this.editorContainer.appendChild(actions); } /** * Set up event handlers for editor interaction */ setupEventHandlers() { // Save button const saveBtn = this.editorContainer.querySelector('.insertr-btn-save'); saveBtn?.addEventListener('click', () => this.handleSave()); // Cancel button const cancelBtn = this.editorContainer.querySelector('.insertr-btn-cancel'); cancelBtn?.addEventListener('click', () => this.handleCancel()); // Content change events if (this.contentEditor) { this.contentEditor.addEventListener('input', () => this.handleChange()); } // Multi-property form changes const propertyFields = this.editorContainer.querySelectorAll('.insertr-property-field'); propertyFields.forEach(field => { field.addEventListener('input', () => this.handleChange()); }); } /** * Apply detected style to selected text or current position * * @param {string} styleId - Style to apply */ applyStyle(styleId) { const styleInfo = this.detectedStyles.get(styleId); if (!styleInfo) { return; } if (this.contentEditor.contentEditable === 'true') { // Rich text editor - apply style to selection this.applyStyleToSelection(styleInfo); } else { // Other editor types - could expand functionality here console.log('Style application for this editor type not yet implemented'); } } /** * Apply style to current selection with intelligent toggle and merging * * @param {Object} styleInfo - Style information */ applyStyleToSelection(styleInfo) { const selection = window.getSelection(); if (selection.rangeCount === 0) { return; } const range = selection.getRangeAt(0); // Expand selection to include adjacent whitespace for better word-level operations const expandedRange = this.expandRangeToIncludeWhitespace(range); // Check if we should toggle or apply the style const selectionAnalysis = this.analyzeSelectionFormatting(expandedRange, styleInfo); if (selectionAnalysis.shouldToggle) { this.removeFormattingFromSelection(expandedRange, styleInfo); } else { this.applyFormattingToSelection(expandedRange, styleInfo, selectionAnalysis); } // Clear selection and trigger change selection.removeAllRanges(); this.handleChange(); } /** * Expand range to include adjacent whitespace for better word-level formatting * This prevents orphaned spaces when formatting partial words * * @param {Range} range - Original selection range * @returns {Range} Expanded range including adjacent whitespace */ expandRangeToIncludeWhitespace(range) { const expandedRange = range.cloneRange(); try { // Check if we should expand the start const startContainer = range.startContainer; if (startContainer.nodeType === Node.TEXT_NODE) { const text = startContainer.textContent; const startOffset = range.startOffset; // Look backwards for whitespace to include let newStartOffset = startOffset; while (newStartOffset > 0 && /\s/.test(text[newStartOffset - 1])) { newStartOffset--; } // Only expand if we found whitespace and we're at a word boundary if (newStartOffset < startOffset && this.isAtWordBoundary(text, startOffset)) { expandedRange.setStart(startContainer, newStartOffset); } } // Check if we should expand the end const endContainer = range.endContainer; if (endContainer.nodeType === Node.TEXT_NODE) { const text = endContainer.textContent; const endOffset = range.endOffset; // Look forwards for whitespace to include let newEndOffset = endOffset; while (newEndOffset < text.length && /\s/.test(text[newEndOffset])) { newEndOffset++; } // Only expand if we found whitespace and we're at a word boundary if (newEndOffset > endOffset && this.isAtWordBoundary(text, endOffset)) { expandedRange.setEnd(endContainer, newEndOffset); } } } catch (e) { // If expansion fails, return original range console.warn('Failed to expand range for whitespace:', e); return range; } return expandedRange; } /** * Check if a position in text is at a word boundary * * @param {string} text - Text content * @param {number} offset - Position to check * @returns {boolean} True if at word boundary */ isAtWordBoundary(text, offset) { const before = offset > 0 ? text[offset - 1] : ''; const at = offset < text.length ? text[offset] : ''; // Word boundary if transitioning between word character and non-word character const beforeIsWord = /\w/.test(before); const atIsWord = /\w/.test(at); return beforeIsWord !== atIsWord; } /** * Analyze selection to determine current formatting state * * @param {Range} range - Selection range * @param {Object} styleInfo - Style information * @returns {Object} Analysis results */ analyzeSelectionFormatting(range, styleInfo) { // Guard against invalid inputs if (!range || !styleInfo || !styleInfo.tagName) { return { shouldToggle: false, coverage: 0, existingElements: [], canMerge: false }; } const targetTag = styleInfo.tagName.toLowerCase(); const targetClasses = styleInfo.classes || []; // Get all nodes in selection const selectedNodes = this.getNodesInRange(range); let formattedNodes = 0; let totalTextNodes = 0; let existingElements = []; // Check if selection has formatted content by examining parent elements let hasFormattedContent = false; let formattedElements = []; // Check start and end containers for formatting const containers = [range.startContainer, range.endContainer]; containers.forEach(container => { let parent = container.nodeType === Node.TEXT_NODE ? container.parentElement : container; while (parent && parent !== this.contentEditor) { if (this.elementMatchesStyle(parent, targetTag, targetClasses)) { hasFormattedContent = true; if (!formattedElements.includes(parent)) { formattedElements.push(parent); } break; // Found formatting, no need to go higher } parent = parent.parentElement; } }); // For partial selections within formatted elements, we should toggle off // For selections that span unformatted content, we should toggle on let shouldToggle = false; if (hasFormattedContent) { // Check if the entire selection is within formatted elements const allElementsFormatted = formattedElements.every(element => { try { const elementRange = document.createRange(); elementRange.selectNodeContents(element); // Check if our selection is completely within this element return range.compareBoundaryPoints(Range.START_TO_START, elementRange) >= 0 && range.compareBoundaryPoints(Range.END_TO_END, elementRange) <= 0; } catch (e) { return false; } }); shouldToggle = allElementsFormatted; } existingElements = formattedElements; return { shouldToggle: shouldToggle, coverage: hasFormattedContent ? 1 : 0, existingElements: existingElements, canMerge: !hasFormattedContent && this.canMergeAdjacent(range, targetTag, targetClasses) }; } /** * Get all nodes within a range * * @param {Range} range - Selection range * @returns {Array} Array of nodes */ getNodesInRange(range) { // Guard against invalid range if (!range || !range.commonAncestorContainer) { return []; } const nodes = []; try { const iterator = document.createNodeIterator( range.commonAncestorContainer, NodeFilter.SHOW_ALL, { acceptNode: (node) => { try { return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } catch (e) { // If intersectsNode fails, exclude the node return NodeFilter.FILTER_REJECT; } } } ); let node; while (node = iterator.nextNode()) { nodes.push(node); } } catch (e) { // If iterator creation fails, return empty array console.warn('Failed to create node iterator:', e); } return nodes; } /** * Find parent element with matching style * * @param {Node} node - Starting node * @param {string} targetTag - Target tag name * @param {Array} targetClasses - Target classes * @returns {Element|null} Matching parent element */ findParentWithStyle(node, targetTag, targetClasses) { let parent = node.parentElement; while (parent && parent !== this.contentEditor) { if (this.elementMatchesStyle(parent, targetTag, targetClasses)) { return parent; } parent = parent.parentElement; } return null; } /** * Check if element matches style criteria * * @param {Element} element - Element to check * @param {string} targetTag - Target tag name * @param {Array} targetClasses - Target classes * @returns {boolean} True if matches */ elementMatchesStyle(element, targetTag, targetClasses) { // Guard against null/undefined elements or missing tagName if (!element || !element.tagName || typeof element.tagName !== 'string') { return false; } if (element.tagName.toLowerCase() !== targetTag) { return false; } // For default styles (no classes), just match the tag if (!targetClasses || targetClasses.length === 0) { return true; } // Guard against missing classList if (!element.classList) { return false; } // For styled elements, match classes return targetClasses.every(className => element.classList.contains(className)); } /** * Get all text nodes within an element * * @param {Element} element - Element to search * @returns {Array} Array of text nodes */ getTextNodesInElement(element) { const textNodes = []; const walker = document.createTreeWalker( element, NodeFilter.SHOW_TEXT, null, false ); let node; while (node = walker.nextNode()) { if (node.textContent.trim()) { textNodes.push(node); } } return textNodes; } /** * Check if adjacent elements can be merged * * @param {Range} range - Selection range * @param {string} targetTag - Target tag name * @param {Array} targetClasses - Target classes * @returns {boolean} True if can merge */ canMergeAdjacent(range, targetTag, targetClasses) { // Guard against invalid range if (!range || !range.startContainer || !range.endContainer) { return false; } // Check if selection starts/ends next to matching elements const startContainer = range.startContainer; const endContainer = range.endContainer; // Check previous sibling of start let prevElement = null; if (startContainer.nodeType === Node.TEXT_NODE) { prevElement = startContainer.previousSibling; } else if (range.startOffset > 0 && startContainer.childNodes) { prevElement = startContainer.childNodes[range.startOffset - 1]; } // Check next sibling of end let nextElement = null; if (endContainer.nodeType === Node.TEXT_NODE) { nextElement = endContainer.nextSibling; } else if (range.endOffset < endContainer.childNodes.length && endContainer.childNodes) { nextElement = endContainer.childNodes[range.endOffset]; } // Only check elements that are actually Element nodes const prevMatches = prevElement && prevElement.nodeType === Node.ELEMENT_NODE && this.elementMatchesStyle(prevElement, targetTag, targetClasses); const nextMatches = nextElement && nextElement.nodeType === Node.ELEMENT_NODE && this.elementMatchesStyle(nextElement, targetTag, targetClasses); return prevMatches || nextMatches; } /** * Remove formatting from selection - properly handles partial selections * * @param {Range} range - Selection range * @param {Object} styleInfo - Style information */ removeFormattingFromSelection(range, styleInfo) { const targetTag = styleInfo.tagName.toLowerCase(); const targetClasses = styleInfo.classes || []; // Find all parent elements that match our target style and contain the selection const matchingParents = this.findMatchingParentsInRange(range, targetTag, targetClasses); // Process each matching parent element matchingParents.forEach(parentElement => { this.splitElementAroundRange(parentElement, range); }); // Also handle direct element matches within the selection const selectedNodes = this.getNodesInRange(range); const elementsToUnwrap = []; selectedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE && this.elementMatchesStyle(node, targetTag, targetClasses) && this.rangeFullyContainsElement(range, node)) { elementsToUnwrap.push(node); } }); // Unwrap elements that are fully contained in the selection elementsToUnwrap.forEach(element => { this.unwrapElement(element); }); } /** * Find all parent elements that match the style and intersect with the range * * @param {Range} range - Selection range * @param {string} targetTag - Target tag name * @param {Array} targetClasses - Target classes * @returns {Array} Array of matching parent elements */ findMatchingParentsInRange(range, targetTag, targetClasses) { const matchingParents = []; const seenElements = new Set(); // Check all containers in the range let container = range.startContainer; // Walk up from start container while (container && container !== this.contentEditor) { if (container.nodeType === Node.ELEMENT_NODE && this.elementMatchesStyle(container, targetTag, targetClasses) && !seenElements.has(container)) { // Check if this element partially overlaps with our selection if (this.elementIntersectsRange(container, range)) { matchingParents.push(container); seenElements.add(container); } } container = container.parentElement; } // Also check from end container if it's different if (range.endContainer !== range.startContainer) { container = range.endContainer; while (container && container !== this.contentEditor) { if (container.nodeType === Node.ELEMENT_NODE && this.elementMatchesStyle(container, targetTag, targetClasses) && !seenElements.has(container)) { if (this.elementIntersectsRange(container, range)) { matchingParents.push(container); seenElements.add(container); } } container = container.parentElement; } } return matchingParents; } /** * Check if an element intersects with a range * * @param {Element} element - Element to check * @param {Range} range - Range to check against * @returns {boolean} True if they intersect */ elementIntersectsRange(element, range) { try { const elementRange = document.createRange(); elementRange.selectNode(element); return range.compareBoundaryPoints(Range.START_TO_END, elementRange) > 0 && elementRange.compareBoundaryPoints(Range.START_TO_END, range) > 0; } catch (e) { return false; } } /** * Check if range fully contains an element * * @param {Range} range - Range to check * @param {Element} element - Element to check * @returns {boolean} True if range fully contains element */ rangeFullyContainsElement(range, element) { try { const elementRange = document.createRange(); elementRange.selectNode(element); return range.compareBoundaryPoints(Range.START_TO_START, elementRange) <= 0 && range.compareBoundaryPoints(Range.END_TO_END, elementRange) >= 0; } catch (e) { return false; } } /** * Apply formatting to selection with smart merging * * @param {Range} range - Selection range * @param {Object} styleInfo - Style information * @param {Object} analysis - Selection analysis */ applyFormattingToSelection(range, styleInfo, analysis) { const targetTag = styleInfo.tagName.toLowerCase(); const targetClasses = styleInfo.classes || []; // Extract selection content const selectedContent = range.extractContents(); // Create new styled element const styledElement = this.styleEngine.createElementFromTemplate( styleInfo, { content: '' } ); // Move selected content into styled element styledElement.appendChild(selectedContent); // Insert the styled element range.insertNode(styledElement); // Try to merge with adjacent elements this.mergeAdjacentElements(styledElement, targetTag, targetClasses); // Normalize whitespace and clean up this.normalizeWhitespace(styledElement.parentNode); } /** * Unwrap an element, moving its children to its parent * * @param {Element} element - Element to unwrap */ unwrapElement(element) { const parent = element.parentNode; while (element.firstChild) { parent.insertBefore(element.firstChild, element); } parent.removeChild(element); } /** * Split element around a range - preserves formatting outside selection * Uses DOM-based approach to maintain exact whitespace and structure * * @param {Element} element - Element to split * @param {Range} range - Range to split around */ splitElementAroundRange(element, range) { try { // Create a more precise splitting approach using DOM structure const parent = element.parentNode; const elementRange = document.createRange(); elementRange.selectNodeContents(element); // Clone the range to avoid modifying the original const workingRange = range.cloneRange(); // Ensure we're working within the element bounds if (workingRange.compareBoundaryPoints(Range.START_TO_START, elementRange) < 0) { workingRange.setStart(elementRange.startContainer, elementRange.startOffset); } if (workingRange.compareBoundaryPoints(Range.END_TO_END, elementRange) > 0) { workingRange.setEnd(elementRange.endContainer, elementRange.endOffset); } // Create ranges for before and after content const beforeRange = document.createRange(); beforeRange.setStart(elementRange.startContainer, elementRange.startOffset); beforeRange.setEnd(workingRange.startContainer, workingRange.startOffset); const afterRange = document.createRange(); afterRange.setStart(workingRange.endContainer, workingRange.endOffset); afterRange.setEnd(elementRange.endContainer, elementRange.endOffset); // Extract content fragments while preserving structure const beforeFragment = beforeRange.cloneContents(); const selectedFragment = workingRange.cloneContents(); const afterFragment = afterRange.cloneContents(); // Extract the actual selected content from the DOM workingRange.extractContents(); // Create before element if it has content if (this.fragmentHasContent(beforeFragment)) { const beforeElement = this.cloneElementStructure(element); beforeElement.appendChild(beforeFragment); parent.insertBefore(beforeElement, element); } // Insert selected content directly (no wrapper) if (this.fragmentHasContent(selectedFragment)) { parent.insertBefore(selectedFragment, element); } // Create after element if it has content if (this.fragmentHasContent(afterFragment)) { const afterElement = this.cloneElementStructure(element); afterElement.appendChild(afterFragment); parent.insertBefore(afterElement, element); } // Remove the original element parent.removeChild(element); } catch (e) { console.warn('Failed to split element around range, falling back to unwrap:', e); this.unwrapElement(element); } } /** * Check if a document fragment has any content (including whitespace) * More permissive than hasSignificantContent - preserves all content * * @param {DocumentFragment} fragment - Fragment to check * @returns {boolean} True if has any content */ fragmentHasContent(fragment) { return fragment && fragment.childNodes && fragment.childNodes.length > 0; } /** * Get text offset of a position within an element * * @param {Element} element - Container element * @param {Node} container - Node containing the position * @param {number} offset - Offset within the container * @returns {number} Text offset within element */ getTextOffsetInElement(element, container, offset) { const elementRange = document.createRange(); elementRange.setStart(element, 0); elementRange.setEnd(container, offset); return elementRange.toString().length; } /** * Clone element structure without content * * @param {Element} element - Element to clone * @returns {Element} Cloned element */ cloneElementStructure(element) { const clone = document.createElement(element.tagName); // Copy attributes for (let i = 0; i < element.attributes.length; i++) { const attr = element.attributes[i]; clone.setAttribute(attr.name, attr.value); } return clone; } /** * Check if document fragment has significant content * Preserves whitespace that could be meaningful in the document flow * * @param {DocumentFragment} fragment - Fragment to check * @returns {boolean} True if has significant content */ hasSignificantContent(fragment) { if (!fragment || !fragment.childNodes) { return false; } // Check if fragment has any element nodes or text nodes with content for (let i = 0; i < fragment.childNodes.length; i++) { const node = fragment.childNodes[i]; if (node.nodeType === Node.ELEMENT_NODE) { return true; } if (node.nodeType === Node.TEXT_NODE && node.textContent.length > 0) { // Don't trim - preserve all whitespace as it could be meaningful // Only exclude completely empty text nodes return true; } } return false; } /** * Merge adjacent elements of the same type * * @param {Element} element - Element to merge with adjacent siblings * @param {string} targetTag - Target tag name * @param {Array} targetClasses - Target classes */ mergeAdjacentElements(element, targetTag, targetClasses) { // Merge with previous sibling let prevSibling = element.previousSibling; if (prevSibling && this.elementMatchesStyle(prevSibling, targetTag, targetClasses)) { // Move content from current element to previous while (element.firstChild) { prevSibling.appendChild(element.firstChild); } element.parentNode.removeChild(element); element = prevSibling; } // Merge with next sibling let nextSibling = element.nextSibling; if (nextSibling && this.elementMatchesStyle(nextSibling, targetTag, targetClasses)) { // Move content from next element to current while (nextSibling.firstChild) { element.appendChild(nextSibling.firstChild); } nextSibling.parentNode.removeChild(nextSibling); } } /** * Normalize whitespace in a container * * @param {Element} container - Container to normalize */ normalizeWhitespace(container) { // Remove empty text nodes and normalize spacing const walker = document.createTreeWalker( container, NodeFilter.SHOW_TEXT, null, false ); const textNodes = []; let node; while (node = walker.nextNode()) { textNodes.push(node); } textNodes.forEach(textNode => { if (!textNode.textContent.trim()) { textNode.parentNode.removeChild(textNode); } }); } /** * Handle save action */ handleSave() { try { const content = this.extractContent(); const success = this.applyContentToElement(content); if (success) { this.onSave(content); } } catch (error) { console.error('Save failed:', error); } } /** * Handle cancel action */ handleCancel() { this.onCancel(); } /** * Handle content change */ handleChange() { try { const content = this.extractContent(); this.onChange(content); } catch (error) { console.error('Change handling failed:', error); } } /** * Extract current content from editor * * @returns {Object} - Extracted content */ extractContent() { 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 - return HTML as-is return { type: 'html', content: this.contentEditor.innerHTML }; } 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 { // 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; } } /** * Get the editor container element * * @returns {HTMLElement} - Editor container */ getEditorElement() { return this.editorContainer; } /** * Open link configuration popup for selected text */ openLinkPopup() { // Get current selection const selection = window.getSelection(); // Check if we have a valid selection in our editor if (!selection.rangeCount || !this.contentEditor.contains(selection.anchorNode)) { alert('Please select some text to create a link'); return; } const range = selection.getRangeAt(0); const selectedText = range.toString().trim(); if (!selectedText) { alert('Please select some text to create a link'); return; } // Check if selection is inside an existing link let existingLink = null; let currentNode = range.commonAncestorContainer; // Walk up the DOM to find if we're inside a link while (currentNode && currentNode !== this.contentEditor) { if (currentNode.nodeType === Node.ELEMENT_NODE && currentNode.tagName.toLowerCase() === 'a') { existingLink = currentNode; break; } currentNode = currentNode.parentNode; } // Get existing URL if editing a link const currentUrl = existingLink ? existingLink.href : ''; const currentTarget = existingLink ? existingLink.target : ''; // Show popup this.showLinkConfigPopup(selectedText, currentUrl, currentTarget, (url, target) => { this.applyLinkToSelection(range, selectedText, url, target, existingLink); }); } /** * Show link configuration popup * * @param {string} text - Selected text * @param {string} currentUrl - Current URL (if editing) * @param {string} currentTarget - Current target (if editing) * @param {Function} onSave - Callback when user saves */ showLinkConfigPopup(text, currentUrl, currentTarget, onSave) { // Create popup overlay const overlay = document.createElement('div'); overlay.className = 'insertr-modal-overlay'; overlay.style.zIndex = '999999'; // Create popup container const popup = document.createElement('div'); popup.className = 'insertr-modal-container'; popup.style.maxWidth = '400px'; // Create unified link configuration form const linkForm = this.createLinkConfiguration({ url: currentUrl, target: currentTarget, showText: false, title: `${currentUrl ? 'Edit' : 'Add'} Link`, onSave: (data) => { onSave(data.url, data.target); document.body.removeChild(overlay); }, onCancel: () => { document.body.removeChild(overlay); }, onRemove: currentUrl ? () => { onSave('', ''); // Empty URL removes the link document.body.removeChild(overlay); } : null }); // Add subtitle with selected text const subtitle = document.createElement('p'); subtitle.style.cssText = ` margin: -12px 0 16px 0; color: var(--insertr-text-secondary); font-size: 14px; `; subtitle.textContent = `Configure link for: "${text}"`; const title = linkForm.querySelector('.insertr-editor-title'); title.parentNode.insertBefore(subtitle, title.nextSibling); // Add actions section const actions = document.createElement('div'); actions.className = 'insertr-form-actions'; const saveBtn = document.createElement('button'); saveBtn.className = 'insertr-btn-save'; saveBtn.textContent = 'Save Link'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'insertr-btn-cancel'; cancelBtn.textContent = 'Cancel'; // Remove link button (if editing existing link) if (currentUrl && linkForm._onRemove) { const removeBtn = document.createElement('button'); removeBtn.className = 'insertr-btn-cancel'; removeBtn.textContent = 'Remove Link'; removeBtn.style.marginRight = 'auto'; actions.appendChild(removeBtn); removeBtn.addEventListener('click', linkForm._onRemove); } actions.appendChild(cancelBtn); actions.appendChild(saveBtn); // Add actions to form linkForm.appendChild(actions); // Event handlers saveBtn.addEventListener('click', linkForm._onSave); cancelBtn.addEventListener('click', linkForm._onCancel); overlay.addEventListener('click', (e) => { if (e.target === overlay) { linkForm._onCancel(); } }); // Assemble popup popup.appendChild(linkForm); overlay.appendChild(popup); // Show popup document.body.appendChild(overlay); // Focus URL input setTimeout(() => { const urlInput = linkForm.querySelector('#link-url'); if (urlInput) { urlInput.focus(); } }, 100); } /** * Apply link to the current selection * * @param {Range} range - Selection range * @param {string} text - Selected text * @param {string} url - Link URL * @param {string} target - Link target * @param {HTMLElement} existingLink - Existing link element (if editing) */ applyLinkToSelection(range, text, url, target, existingLink) { if (!url) { // Remove link if no URL provided if (existingLink) { const parent = existingLink.parentNode; while (existingLink.firstChild) { parent.insertBefore(existingLink.firstChild, existingLink); } parent.removeChild(existingLink); } return; } if (existingLink) { // Update existing link existingLink.href = url; if (target) { existingLink.target = target; } else { existingLink.removeAttribute('target'); } } else { // Create new link const link = document.createElement('a'); link.href = url; if (target) { link.target = target; } try { range.surroundContents(link); } catch (e) { // Fallback for complex selections link.textContent = text; range.deleteContents(); range.insertNode(link); } } // Clear selection window.getSelection().removeAllRanges(); // Trigger change event this.onChange(); } /** * Add validation to link editor */ addLinkValidation(form) { const textInput = form.querySelector('#link-text'); const urlInput = form.querySelector('#link-url'); const textMessage = form.querySelector('#link-text-message'); const urlMessage = form.querySelector('#link-url-message'); // URL validation (URL input should always exist) if (urlInput && urlMessage) { urlInput.addEventListener('input', () => { const url = urlInput.value.trim(); if (!url) { this.setValidationState(urlInput, urlMessage, '', 'info'); return; } try { new URL(url); this.setValidationState(urlInput, urlMessage, '✓ Valid URL', 'success'); } catch (e) { if (url.includes('.') && !url.startsWith('http')) { // Suggest adding protocol this.setValidationState(urlInput, urlMessage, 'Try adding https:// to the beginning', 'info'); } else { this.setValidationState(urlInput, urlMessage, 'Please enter a valid URL', 'error'); } } }); } // Text validation (only if text input exists - not present in popups) if (textInput && textMessage) { textInput.addEventListener('input', () => { const text = textInput.value.trim(); if (!text) { this.setValidationState(textInput, textMessage, 'Link text is required', 'error'); } else { this.setValidationState(textInput, textMessage, '', 'info'); } }); } } /** * Set validation state for form elements */ setValidationState(input, messageElement, message, type) { // Guard against null elements if (!input || !messageElement) { return; } // Remove previous states input.classList.remove('insertr-error', 'insertr-success'); messageElement.classList.remove('insertr-error', 'insertr-success', 'insertr-info'); // Add new state if (type === 'error') { input.classList.add('insertr-error'); messageElement.classList.add('insertr-error'); } else if (type === 'success') { input.classList.add('insertr-success'); messageElement.classList.add('insertr-success'); } else { messageElement.classList.add('insertr-info'); } // Set message messageElement.textContent = message; messageElement.style.display = message ? 'block' : 'none'; } /** * HTML escape utility */ escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Destroy the editor and clean up */ destroy() { // Clean up selection change listeners if (this._selectionChangeCleanup) { this._selectionChangeCleanup(); this._selectionChangeCleanup = null; } if (this.editorContainer && this.editorContainer.parentNode) { this.editorContainer.parentNode.removeChild(this.editorContainer); } this.isInitialized = false; this.editorContainer = null; this.contentEditor = null; this.toolbar = null; this.styleButtons = null; } }