- 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
1947 lines
68 KiB
JavaScript
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;
|
|
}
|
|
}
|