Files
insertr/lib/src/ui/style-aware-editor.js
Joakim b25663f76b Unify all formatting buttons to use consistent three-layer architecture
- Remove .insertr-default-style special styling (blue background, border, dot indicator)
- Make ALL buttons use .insertr-style-preview with unified appearance
- Maintain authentic style previews in isolated .insertr-style-sample layer
- Bold buttons show bold text, .brand buttons show green uppercase text, etc.
- Eliminate visual inconsistency between semantic and detected style buttons
- Simplify CSS by removing ~50 lines of duplicate button styling
- Provide consistent professional toolbar appearance across all formatting options
2025-09-22 14:18:57 +02:00

1947 lines
68 KiB
JavaScript

/**
* 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 = `
<label class="insertr-form-label">Link Text</label>
<input type="text" class="insertr-form-input" id="link-text" value="${this.escapeHtml(text)}" placeholder="Enter link text" required>
<div class="insertr-form-message insertr-info" style="display: none;" id="link-text-message"></div>
`;
form.appendChild(textGroup);
}
// URL field with validation
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.escapeHtml(url)}" placeholder="https://example.com" required>
<div class="insertr-form-message insertr-info" style="display: none;" id="link-url-message"></div>
`;
form.appendChild(urlGroup);
// Target field
const targetGroup = document.createElement('div');
targetGroup.className = 'insertr-form-group';
targetGroup.innerHTML = `
<label class="insertr-form-label">Open In</label>
<select class="insertr-form-select" id="link-target">
<option value="">Same window</option>
<option value="_blank" ${target === '_blank' ? 'selected' : ''}>New window/tab</option>
</select>
<div class="insertr-form-message insertr-info">Choose how the link opens when clicked</div>
`;
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 = `
<label class="insertr-form-label">Button Text</label>
<input type="text" class="insertr-form-input" id="button-text" value="${this.escapeHtml(this.element.textContent)}" placeholder="Enter button text" required>
<div class="insertr-form-message insertr-info">This text will appear on the button</div>
`;
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 = `
<label class="insertr-form-label">Image URL</label>
<input type="url" class="insertr-form-input" id="image-src" value="${this.escapeHtml(this.element.src || '')}" placeholder="https://example.com/image.jpg" required>
<div class="insertr-form-message insertr-info">Enter the full URL to the image file</div>
`;
// 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.escapeHtml(this.element.alt || '')}" placeholder="Describe this image for accessibility" required>
<div class="insertr-form-message insertr-info">Describe the image for screen readers and accessibility</div>
`;
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 = `
<label class="insertr-form-label">Current Image</label>
<img src="${this.escapeHtml(this.element.src)}" alt="${this.escapeHtml(this.element.alt || '')}" style="max-width: 200px; max-height: 150px; border: 1px solid var(--insertr-border-color); border-radius: var(--insertr-border-radius);">
`;
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;
}
}