From d44bdd41b4b06f7bac147b6da71457c6f3cd9c3f Mon Sep 17 00:00:00 2001 From: Joakim Date: Sun, 21 Sep 2025 20:47:22 +0200 Subject: [PATCH] feat: unify link editing interfaces with comprehensive polish Multi-Property Editor Polish: - Add comprehensive form styling (.insertr-form-group, .insertr-form-input, etc.) - Professional layout with titles, validation, auto-focus, and help text - Enhanced link/button/image editors with real-time validation - Consistent spacing, colors, and visual hierarchy Smart Default Formatting: - Add Bold, Italic, Link options when not detected in content - Intelligent detection respects existing developer styles - Visual distinction for default vs detected styles with info-colored borders - Content-aware: only adds to elements that benefit from text formatting Link Interface Unification: - Create shared createLinkConfigurationForm() component - Eliminate code duplication between direct editing and popup creation - Update createLinkEditor() and showLinkConfigPopup() to use shared component - Fix link button styling to match other style buttons with preview content Benefits: - Consistent professional editing experience across all interfaces - Reduced maintenance burden through code unification - Enhanced UX with validation, keyboard shortcuts, and visual feedback - Maintains CLASSES.md philosophy while improving out-of-box experience --- lib/src/styles/insertr.css | 159 +++++++ lib/src/ui/style-aware-editor.js | 682 +++++++++++++++++++++---------- lib/src/utils/style-detection.js | 198 ++++++--- 3 files changed, 760 insertions(+), 279 deletions(-) diff --git a/lib/src/styles/insertr.css b/lib/src/styles/insertr.css index f8e0900..02ae0a9 100644 --- a/lib/src/styles/insertr.css +++ b/lib/src/styles/insertr.css @@ -442,6 +442,46 @@ body:not(.insertr-edit-mode) .insertr-editing-hover::after { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); } +/* Default formatting style buttons */ +.insertr-style-btn.insertr-default-style { + border-color: var(--insertr-info); + background: rgba(23, 162, 184, 0.1); + position: relative; +} + +.insertr-style-btn.insertr-default-style:hover { + border-color: var(--insertr-info); + background: rgba(23, 162, 184, 0.2); + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(23, 162, 184, 0.3); +} + +.insertr-style-btn.insertr-default-style:active { + transform: translateY(0); + box-shadow: 0 1px 2px rgba(23, 162, 184, 0.2); +} + +/* Default preview content styling */ +.insertr-default-preview { + font-size: var(--insertr-font-size-sm); + font-weight: 500; + padding: var(--insertr-spacing-xs) var(--insertr-spacing-sm); + display: inline-block; +} + +/* Small indicator for default styles */ +.insertr-style-btn.insertr-default-style::after { + content: ''; + position: absolute; + top: 2px; + right: 2px; + width: 6px; + height: 6px; + background: var(--insertr-info); + border-radius: 50%; + opacity: 0.7; +} + /* Editor components */ .insertr-simple-editor, .insertr-rich-editor, @@ -479,7 +519,126 @@ body:not(.insertr-edit-mode) .insertr-editing-hover::after { overflow-y: auto; } +/* ================================================================= + MULTI-PROPERTY FORM COMPONENTS + Professional form styling for direct editors (links, buttons, images) + ================================================================= */ +/* Direct editor container */ +.insertr-direct-editor { + background: var(--insertr-bg-primary); + border: 1px solid var(--insertr-border-color); + border-radius: var(--insertr-border-radius); + padding: var(--insertr-spacing-lg); + min-width: 400px; + max-width: 600px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + font-family: var(--insertr-font-family); + color: var(--insertr-text-primary); +} + +/* Form groups */ +.insertr-form-group { + margin-bottom: var(--insertr-spacing-md); +} + +.insertr-form-group:last-child { + margin-bottom: 0; +} + +/* Form labels */ +.insertr-form-label { + display: block; + margin-bottom: var(--insertr-spacing-xs); + font-weight: 500; + font-size: var(--insertr-font-size-sm); + color: var(--insertr-text-primary); + line-height: 1.4; +} + +/* Form inputs and selects */ +.insertr-form-input, +.insertr-form-select { + width: 100%; + padding: var(--insertr-spacing-sm) var(--insertr-spacing-md); + border: 1px solid var(--insertr-border-color); + border-radius: var(--insertr-border-radius); + font-size: var(--insertr-font-size-base); + font-family: var(--insertr-font-family); + line-height: 1.4; + color: var(--insertr-text-primary); + background: var(--insertr-bg-primary); + transition: var(--insertr-transition); + box-sizing: border-box; +} + +/* Focus states */ +.insertr-form-input:focus, +.insertr-form-select:focus { + outline: none; + border-color: var(--insertr-primary); + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); +} + +/* Hover states for selects */ +.insertr-form-select:hover { + border-color: var(--insertr-text-secondary); +} + +/* Disabled states */ +.insertr-form-input:disabled, +.insertr-form-select:disabled { + background: var(--insertr-bg-secondary); + color: var(--insertr-text-muted); + cursor: not-allowed; + opacity: 0.6; +} + +/* Error states */ +.insertr-form-input.insertr-error, +.insertr-form-select.insertr-error { + border-color: var(--insertr-danger); + box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.25); +} + +/* Success states */ +.insertr-form-input.insertr-success, +.insertr-form-select.insertr-success { + border-color: var(--insertr-success); + box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.25); +} + +/* Form validation messages */ +.insertr-form-message { + margin-top: var(--insertr-spacing-xs); + font-size: var(--insertr-font-size-sm); + line-height: 1.3; +} + +.insertr-form-message.insertr-error { + color: var(--insertr-danger); +} + +.insertr-form-message.insertr-success { + color: var(--insertr-success); +} + +.insertr-form-message.insertr-info { + color: var(--insertr-info); +} + +/* Specific editor variants */ +.insertr-link-editor { + /* Link-specific styling if needed */ +} + +.insertr-button-editor { + /* Button-specific styling if needed */ +} + +.insertr-image-editor { + /* Image-specific styling if needed */ +} /* Form actions */ .insertr-form-actions { diff --git a/lib/src/ui/style-aware-editor.js b/lib/src/ui/style-aware-editor.js index 895d9b1..295a8ca 100644 --- a/lib/src/ui/style-aware-editor.js +++ b/lib/src/ui/style-aware-editor.js @@ -20,11 +20,11 @@ export class StyleAwareEditor { preserveAttributes: true, ...options }; - + // Core system components this.styleEngine = styleDetectionEngine; this.htmlEngine = htmlPreservationEngine; - + // Editor state this.isInitialized = false; this.editorContainer = null; @@ -33,11 +33,11 @@ export class StyleAwareEditor { this.detectedStyles = null; this.contentStructure = null; this.originalContent = null; - + // Event callbacks - this.onSave = options.onSave || (() => {}); - this.onCancel = options.onCancel || (() => {}); - this.onChange = options.onChange || (() => {}); + this.onSave = options.onSave || (() => { }); + this.onCancel = options.onCancel || (() => { }); + this.onChange = options.onChange || (() => { }); } /** @@ -52,15 +52,15 @@ export class StyleAwareEditor { 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; @@ -75,15 +75,15 @@ export class StyleAwareEditor { 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, @@ -101,16 +101,16 @@ export class StyleAwareEditor { */ 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 * @@ -133,7 +133,7 @@ export class StyleAwareEditor { // 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': @@ -143,12 +143,13 @@ export class StyleAwareEditor { this.createRichEditor(analysis); break; } - - // Add toolbar if enabled and we have styled content (rich editor only) + + // 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(); } @@ -158,7 +159,7 @@ export class StyleAwareEditor { */ createDirectEditor(analysis) { const tagName = this.element.tagName.toLowerCase(); - + if (tagName === 'a') { this.createLinkEditor(); } else if (tagName === 'button') { @@ -170,49 +171,221 @@ export class StyleAwareEditor { this.createRichEditor(analysis); } } - + /** - * Create link editor with URL and text fields + * Create unified link configuration component + * Used by both direct link editing and popup link creation + * + * @param {Object} options - Configuration options + * @param {string} options.text - Link text + * @param {string} options.url - Current URL + * @param {string} options.target - Current target + * @param {string} options.mode - 'direct' or 'popup' + * @param {boolean} options.showTextField - Whether to show text editing + * @param {Function} options.onSave - Save callback + * @param {Function} options.onCancel - Cancel callback + * @param {Function} options.onRemove - Remove callback (optional) + * @returns {HTMLElement} - The link configuration form */ - createLinkEditor() { + createLinkConfigurationForm(options = {}) { + const { + text = '', + url = '', + target = '', + mode = 'direct', + showTextField = true, + onSave = () => {}, + onCancel = () => {}, + onRemove = null + } = options; + + // Create form container const form = document.createElement('div'); form.className = 'insertr-direct-editor insertr-link-editor'; - // Text field - const textGroup = document.createElement('div'); - textGroup.className = 'insertr-form-group'; - textGroup.innerHTML = ` - - + // Create title + const title = document.createElement('h3'); + title.className = 'insertr-editor-title'; + title.textContent = url ? 'Edit Link' : 'Add Link'; + 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); + + // Add help text for popup mode + if (mode === 'popup' && text) { + const helpText = document.createElement('p'); + helpText.className = 'insertr-form-message insertr-info'; + helpText.textContent = `Configure link for: "${text}"`; + helpText.style.marginBottom = 'var(--insertr-spacing-md)'; + form.appendChild(helpText); + } - // URL field + // Text field (only show if requested) + if (showTextField) { + const textGroup = document.createElement('div'); + textGroup.className = 'insertr-form-group'; + textGroup.innerHTML = ` + + + + `; + form.appendChild(textGroup); + } + + // URL field with validation const urlGroup = document.createElement('div'); urlGroup.className = 'insertr-form-group'; urlGroup.innerHTML = ` - + + `; + form.appendChild(urlGroup); // Target field const targetGroup = document.createElement('div'); targetGroup.className = 'insertr-form-group'; targetGroup.innerHTML = ` - + +
Choose how the link opens when clicked
`; - - form.appendChild(textGroup); - form.appendChild(urlGroup); form.appendChild(targetGroup); + // Form actions + const actions = document.createElement('div'); + actions.className = 'insertr-form-actions'; + + // Save button + const saveBtn = document.createElement('button'); + saveBtn.type = 'button'; + saveBtn.className = 'insertr-btn-save'; + saveBtn.textContent = url ? 'Update Link' : 'Create Link'; + + // Cancel button + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.className = 'insertr-btn-cancel'; + cancelBtn.textContent = 'Cancel'; + + // Remove button (if editing existing link) + if (url && onRemove) { + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'insertr-btn-cancel'; + removeBtn.textContent = 'Remove Link'; + removeBtn.style.marginRight = 'auto'; + actions.appendChild(removeBtn); + + removeBtn.addEventListener('click', (e) => { + e.preventDefault(); + onRemove(); + }); + } + + actions.appendChild(cancelBtn); + actions.appendChild(saveBtn); + form.appendChild(actions); + + // Add validation + this.addLinkValidation(form); + + // Event handlers + saveBtn.addEventListener('click', (e) => { + e.preventDefault(); + + const linkText = showTextField ? document.getElementById('link-text')?.value || text : text; + const linkUrl = document.getElementById('link-url').value; + const linkTarget = document.getElementById('link-target').value; + + // Basic validation + if (showTextField && !linkText.trim()) { + this.setValidationState( + document.getElementById('link-text'), + document.getElementById('link-text-message'), + 'Link text is required', + 'error' + ); + return; + } + + if (!linkUrl.trim()) { + this.setValidationState( + document.getElementById('link-url'), + document.getElementById('link-url-message'), + 'URL is required', + 'error' + ); + return; + } + + onSave({ + text: linkText, + url: linkUrl, + target: linkTarget + }); + }); + + cancelBtn.addEventListener('click', (e) => { + e.preventDefault(); + onCancel(); + }); + + // Auto-focus first input + setTimeout(() => { + const firstInput = form.querySelector(showTextField ? '#link-text' : '#link-url'); + if (firstInput) { + firstInput.focus(); + firstInput.select(); + } + }, 100); + + return form; + } + + /** + * Create link editor with URL and text fields + */ + createLinkEditor() { + const form = this.createLinkConfigurationForm({ + text: this.element.textContent, + url: this.element.href || '', + target: this.element.target || '', + mode: 'direct', + showTextField: true, + onSave: (linkData) => { + // Apply the changes to the element + this.element.textContent = linkData.text; + this.element.href = linkData.url; + + if (linkData.target) { + this.element.target = linkData.target; + } else { + this.element.removeAttribute('target'); + } + + // Trigger change event + this.onChange(); + + // Close editor (will be handled by parent) + this.destroy(); + }, + onCancel: () => { + this.destroy(); + } + }); + this.contentEditor = form; this.editorContainer.appendChild(form); } - + /** * Create button editor with text field */ @@ -220,20 +393,42 @@ export class StyleAwareEditor { const form = document.createElement('div'); form.className = 'insertr-direct-editor insertr-button-editor'; + // Create a title for the editor + const title = document.createElement('h3'); + title.className = 'insertr-editor-title'; + title.textContent = 'Edit Button'; + title.style.cssText = ` + margin: 0 0 ${getComputedStyle(document.documentElement).getPropertyValue('--insertr-spacing-md') || '16px'} 0; + font-size: 18px; + font-weight: 600; + color: var(--insertr-text-primary); + `; + form.appendChild(title); + // Text field const textGroup = document.createElement('div'); textGroup.className = 'insertr-form-group'; textGroup.innerHTML = ` - + +
This text will appear on the button
`; form.appendChild(textGroup); this.contentEditor = form; this.editorContainer.appendChild(form); + + // Focus the input + setTimeout(() => { + const textInput = form.querySelector('#button-text'); + if (textInput) { + textInput.focus(); + textInput.select(); + } + }, 100); } - + /** * Create image editor with src and alt fields */ @@ -241,12 +436,25 @@ export class StyleAwareEditor { const form = document.createElement('div'); form.className = 'insertr-direct-editor insertr-image-editor'; + // Create a title for the editor + const title = document.createElement('h3'); + title.className = 'insertr-editor-title'; + title.textContent = 'Edit Image'; + title.style.cssText = ` + margin: 0 0 ${getComputedStyle(document.documentElement).getPropertyValue('--insertr-spacing-md') || '16px'} 0; + font-size: 18px; + font-weight: 600; + color: var(--insertr-text-primary); + `; + form.appendChild(title); + // Source field const srcGroup = document.createElement('div'); srcGroup.className = 'insertr-form-group'; srcGroup.innerHTML = ` - + +
Enter the full URL to the image file
`; // Alt text field @@ -254,14 +462,35 @@ export class StyleAwareEditor { altGroup.className = 'insertr-form-group'; altGroup.innerHTML = ` - + +
Describe the image for screen readers and accessibility
`; form.appendChild(srcGroup); form.appendChild(altGroup); + // Add image preview if src exists + if (this.element.src) { + const previewGroup = document.createElement('div'); + previewGroup.className = 'insertr-form-group'; + previewGroup.innerHTML = ` + + ${this.escapeHtml(this.element.alt || '')} + `; + form.appendChild(previewGroup); + } + this.contentEditor = form; this.editorContainer.appendChild(form); + + // Focus the first input + setTimeout(() => { + const srcInput = form.querySelector('#image-src'); + if (srcInput) { + srcInput.focus(); + srcInput.select(); + } + }, 100); } /** @@ -275,17 +504,11 @@ export class StyleAwareEditor { 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 * @@ -295,33 +518,33 @@ export class StyleAwareEditor { 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); - + // 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); } - + // Add link button if we have links in content or always for rich editor - const hasLinks = structure.some(piece => + 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); } @@ -339,15 +562,32 @@ export class StyleAwareEditor { button.className = 'insertr-style-btn'; button.title = `Apply ${styleInfo.name} style`; button.dataset.styleId = styleId; - + // Create preview content container const previewContent = document.createElement('span'); previewContent.className = 'insertr-preview-content'; previewContent.textContent = styleInfo.name; - - // Apply the original classes to the preview content (not the button) - if (styleInfo.element && styleInfo.classes && styleInfo.classes.length > 0) { - // Add special preview button class to the button + + // Apply styling based on whether this is a detected style or default + if (styleInfo.isDefault) { + // Default style - apply semantic styling + button.classList.add('insertr-default-style'); + previewContent.classList.add('insertr-default-preview'); + + // Add semantic styling for default elements + if (styleInfo.tagName === 'strong') { + previewContent.style.fontWeight = 'bold'; + } else if (styleInfo.tagName === 'em') { + previewContent.style.fontStyle = 'italic'; + } else if (styleInfo.tagName === 'a') { + previewContent.style.textDecoration = 'underline'; + previewContent.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 preview button.classList.add('insertr-style-preview'); // Add the detected classes to the preview content @@ -358,16 +598,16 @@ export class StyleAwareEditor { // No meaningful styles detected - use fallback previewContent.classList.add('insertr-fallback-style'); } - + // Add the preview content to the button button.appendChild(previewContent); - + // Add click handler for style application button.addEventListener('click', (e) => { e.preventDefault(); this.applyStyle(styleId); }); - + return button; } @@ -379,16 +619,26 @@ export class StyleAwareEditor { createLinkButton() { const button = document.createElement('button'); button.type = 'button'; - button.className = 'insertr-style-btn'; - button.textContent = '🔗 Link'; - button.title = 'Add/Edit Link'; + button.className = 'insertr-style-btn insertr-default-style'; + button.title = 'Add/Edit Link: Create hyperlinks in your content'; + + // Create preview content container to match other buttons + const previewContent = document.createElement('span'); + previewContent.className = 'insertr-preview-content insertr-default-preview'; + previewContent.textContent = 'Link'; + // Apply link styling for visual consistency + previewContent.style.textDecoration = 'underline'; + previewContent.style.color = '#0066cc'; + + button.appendChild(previewContent); + // Add click handler for link configuration button.addEventListener('click', (e) => { e.preventDefault(); this.openLinkPopup(); }); - + return button; } @@ -398,20 +648,20 @@ export class StyleAwareEditor { 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); } @@ -422,16 +672,16 @@ export class StyleAwareEditor { // 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 => { @@ -449,7 +699,7 @@ export class StyleAwareEditor { if (!styleInfo) { return; } - + if (this.contentEditor.contentEditable === 'true') { // Rich text editor - apply style to selection this.applyStyleToSelection(styleInfo); @@ -469,24 +719,24 @@ export class StyleAwareEditor { if (selection.rangeCount === 0) { return; } - + const range = selection.getRangeAt(0); const selectedText = range.toString(); - + if (selectedText) { // Create styled element const styledElement = this.styleEngine.createElementFromTemplate( - styleInfo, + styleInfo, { content: selectedText } ); - + // Replace selection with styled element range.deleteContents(); range.insertNode(styledElement); - + // Clear selection selection.removeAllRanges(); - + // Trigger change event this.handleChange(); } @@ -499,7 +749,7 @@ export class StyleAwareEditor { try { const content = this.extractContent(); const success = this.applyContentToElement(content); - + if (success) { this.onSave(content); } @@ -529,7 +779,6 @@ export class StyleAwareEditor { /** * Extract current content from editor - * HTML-first approach: always return HTML content * * @returns {Object} - Extracted content */ @@ -544,10 +793,10 @@ export class StyleAwareEditor { content: this.contentEditor.innerHTML }; } - + return null; } - + /** * Extract content from direct property editors * @@ -556,19 +805,12 @@ export class StyleAwareEditor { 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') { + // Note: Link editing is now handled directly in createLinkConfigurationForm + // This method only handles button and image elements + + if (tagName === 'button') { const text = document.getElementById('button-text').value; - + return { type: 'html', content: text @@ -576,14 +818,14 @@ export class StyleAwareEditor { } 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; } @@ -608,12 +850,12 @@ export class StyleAwareEditor { } } } - + // 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); @@ -636,25 +878,25 @@ export class StyleAwareEditor { 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') { @@ -663,11 +905,11 @@ export class StyleAwareEditor { } 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); @@ -687,116 +929,52 @@ export class StyleAwareEditor { 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 form - const form = document.createElement('div'); - form.className = 'insertr-edit-form'; - - // Header - const header = document.createElement('div'); - header.className = 'insertr-form-header'; - header.innerHTML = ` -

${currentUrl ? 'Edit' : 'Add'} Link

-

Configure link for: "${text}"

- `; - - // Form body - const body = document.createElement('div'); - body.className = 'insertr-form-body'; - - // URL input - const urlGroup = document.createElement('div'); - urlGroup.className = 'insertr-form-group'; - urlGroup.innerHTML = ` - - - `; - - // Target input - const targetGroup = document.createElement('div'); - targetGroup.className = 'insertr-form-group'; - targetGroup.innerHTML = ` - - - `; - - body.appendChild(urlGroup); - body.appendChild(targetGroup); - - // Actions - 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) { - const removeBtn = document.createElement('button'); - removeBtn.className = 'insertr-btn-cancel'; - removeBtn.textContent = 'Remove Link'; - removeBtn.style.marginRight = 'auto'; - actions.appendChild(removeBtn); - - removeBtn.addEventListener('click', () => { - onSave('', ''); // Empty URL removes the link + popup.style.maxWidth = '600px'; // Wider to accommodate polished form + + // Create unified form using shared component + const form = this.createLinkConfigurationForm({ + text: text, + url: currentUrl, + target: currentTarget, + mode: 'popup', + showTextField: false, // Don't show text field for popup (text is already selected) + onSave: (linkData) => { + onSave(linkData.url, linkData.target); document.body.removeChild(overlay); - }); - } - - actions.appendChild(cancelBtn); - actions.appendChild(saveBtn); - - // Assemble popup - form.appendChild(header); - form.appendChild(body); - form.appendChild(actions); + }, + onCancel: () => { + document.body.removeChild(overlay); + }, + onRemove: currentUrl ? () => { + // Call onSave with empty string to indicate removal + onSave('', ''); + document.body.removeChild(overlay); + } : null + }); + popup.appendChild(form); overlay.appendChild(popup); - - // Event handlers - saveBtn.addEventListener('click', () => { - const url = document.getElementById('link-url').value.trim(); - const target = document.getElementById('link-target').value; - - if (url) { - onSave(url, target); - document.body.removeChild(overlay); - } else { - alert('Please enter a valid URL'); - } - }); - - cancelBtn.addEventListener('click', () => { - document.body.removeChild(overlay); - }); - + document.body.appendChild(overlay); + + // Close on overlay click overlay.addEventListener('click', (e) => { if (e.target === overlay) { document.body.removeChild(overlay); } }); - - // Show popup - document.body.appendChild(overlay); - - // Focus URL input - setTimeout(() => { - document.getElementById('link-url').focus(); - }, 100); + + // Handle escape key + const handleEscape = (e) => { + if (e.key === 'Escape') { + document.body.removeChild(overlay); + document.removeEventListener('keydown', handleEscape); + } + }; + document.addEventListener('keydown', handleEscape); } /** @@ -820,7 +998,7 @@ export class StyleAwareEditor { } return; } - + if (existingLink) { // Update existing link existingLink.href = url; @@ -836,7 +1014,7 @@ export class StyleAwareEditor { if (target) { link.target = target; } - + try { range.surroundContents(link); } catch (e) { @@ -846,14 +1024,90 @@ export class StyleAwareEditor { 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 + 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 + 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) { + // 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 */ @@ -861,10 +1115,10 @@ export class StyleAwareEditor { if (this.editorContainer && this.editorContainer.parentNode) { this.editorContainer.parentNode.removeChild(this.editorContainer); } - + this.isInitialized = false; this.editorContainer = null; this.contentEditor = null; this.toolbar = null; } -} \ No newline at end of file +} diff --git a/lib/src/utils/style-detection.js b/lib/src/utils/style-detection.js index 42e41f4..5a476b2 100644 --- a/lib/src/utils/style-detection.js +++ b/lib/src/utils/style-detection.js @@ -1,9 +1,6 @@ /** * StyleDetectionEngine - Analyzes elements for nested styled children with position preservation * - * Implements the "one layer deep" analysis described in CLASSES.md line 27: - * "Only direct child elements are analyzed and preserved" - * * Purpose: Extract styled nested elements as formatting options AND preserve their positions */ @@ -21,6 +18,7 @@ export class StyleDetectionEngine { /** * Analyze element for nested styled elements AND their positions (CLASSES.md line 26-29) * Returns both detected styles and structured content that preserves positions + * Enhanced with smart default formatting options when not present * * @param {HTMLElement} element - The .insertr element to analyze * @returns {Object} - {styles: Map, structure: Array} @@ -32,23 +30,15 @@ export class StyleDetectionEngine { // Parse the element's content while preserving structure this.parseContentStructure(element, styleMap, contentStructure); + // Add smart default formatting options if not already present + this.addSmartDefaults(styleMap, element); + return { styles: styleMap, structure: contentStructure }; } - /** - * Legacy method for backward compatibility - only returns styles - * - * @param {HTMLElement} element - The .insertr element to analyze - * @returns {Map} - Map of styleId -> styleInfo objects - */ - detectStyles(element) { - const result = this.detectStylesAndStructure(element); - return result.styles; - } - /** * Parse content structure while collecting style information * Creates a structure array that preserves text and styled element positions @@ -71,14 +61,14 @@ export class StyleDetectionEngine { } else if (node.nodeType === (typeof Node !== 'undefined' ? Node.ELEMENT_NODE : NODE_TYPES.ELEMENT_NODE)) { // Element node - analyze for styling and extract editable properties const styleInfo = this.analyzeElement(node); - + if (styleInfo) { // Styled element - add to both style map and structure styleMap.set(styleInfo.id, styleInfo); - + // Extract all editable properties for this element const editableProperties = this.extractEditableProperties(node); - + structure.push({ type: 'styled', styleId: styleInfo.id, @@ -88,7 +78,7 @@ export class StyleDetectionEngine { } else { // Unstyled element - treat as text structure.push({ - type: 'text', + type: 'text', content: node.textContent }); } @@ -106,10 +96,10 @@ export class StyleDetectionEngine { extractEditableProperties(element) { const tagName = element.tagName.toLowerCase(); const properties = {}; - + // Always include text content as the primary editable property properties.content = element.textContent; - + // Add element-specific editable properties switch (tagName) { case 'a': @@ -119,13 +109,13 @@ export class StyleDetectionEngine { properties.target = element.getAttribute('target'); } break; - + case 'img': properties.src = element.src || ''; properties.alt = element.alt || ''; properties.title = element.title || ''; break; - + case 'button': properties.content = element.textContent; if (element.type) { @@ -135,7 +125,7 @@ export class StyleDetectionEngine { properties.disabled = element.disabled; } break; - + case 'input': properties.value = element.value || ''; properties.placeholder = element.placeholder || ''; @@ -143,12 +133,12 @@ export class StyleDetectionEngine { properties.type = element.type; } break; - + default: // For other elements, content is the main editable property break; } - + return properties; } @@ -162,16 +152,16 @@ export class StyleDetectionEngine { const tagName = element.tagName.toLowerCase(); const classes = Array.from(element.classList); const attributes = this.extractElementAttributes(element); - + // Skip elements without styling (no classes or special attributes) if (classes.length === 0 && !this.hasSignificantAttributes(attributes)) { return null; } - + // Generate unique style ID and human-readable name const styleId = this.generateStyleId(tagName, classes, attributes); const styleName = this.generateStyleName(tagName, classes, attributes); - + return { id: styleId, name: styleName, @@ -193,18 +183,18 @@ export class StyleDetectionEngine { extractElementAttributes(element) { const attributes = {}; const skipAttributes = new Set(['class', 'id']); // These are handled separately - + for (const attr of element.attributes) { if (!skipAttributes.has(attr.name)) { attributes[attr.name] = attr.value; } } - + // Include ID if present (it's significant for styling) if (element.id) { attributes.id = element.id; } - + return attributes; } @@ -216,8 +206,8 @@ export class StyleDetectionEngine { */ hasSignificantAttributes(attributes) { // Consider data-*, aria-*, href, rel, target, etc. as significant - return Object.keys(attributes).some(key => - key.startsWith('data-') || + return Object.keys(attributes).some(key => + key.startsWith('data-') || key.startsWith('aria-') || key === 'href' || key === 'rel' || @@ -255,17 +245,17 @@ export class StyleDetectionEngine { if (this.styleNameMappings.has(key)) { return this.styleNameMappings.get(key); } - + // Generate name from class names if (classes.length > 0) { return this.classesToDisplayName(classes); } - + // Generate name from tag + attributes if (attributes.id) { return this.tagToDisplayName(tagName) + ' (' + attributes.id + ')'; } - + // Fallback to tag name return this.tagToDisplayName(tagName); } @@ -276,7 +266,7 @@ export class StyleDetectionEngine { */ initializeStyleMappings() { const mappings = new Map(); - + // From demo examples in simple/index.html mappings.set('strong.emph', 'Emphasis'); mappings.set('strong.brand', 'Brand'); @@ -289,14 +279,14 @@ export class StyleDetectionEngine { mappings.set('blockquote.testimonial', 'Testimonial'); mappings.set('i.icon-home', 'Home Icon'); mappings.set('i.icon-info', 'Info Icon'); - + // Common patterns mappings.set('strong.highlight', 'Highlight Bold'); mappings.set('span.brand', 'Brand Style'); mappings.set('a.button', 'Button Link'); mappings.set('span.tag', 'Tag'); mappings.set('span.badge', 'Badge'); - + return mappings; } @@ -324,7 +314,7 @@ export class StyleDetectionEngine { tagToDisplayName(tagName) { const tagMappings = { 'strong': 'Bold', - 'em': 'Italic', + 'em': 'Italic', 'span': 'Style', 'a': 'Link', 'button': 'Button', @@ -332,7 +322,7 @@ export class StyleDetectionEngine { 'code': 'Code', 'blockquote': 'Quote' }; - + return tagMappings[tagName] || tagName.charAt(0).toUpperCase() + tagName.slice(1); } @@ -346,19 +336,19 @@ export class StyleDetectionEngine { extractTemplate(element) { const tagName = element.tagName.toLowerCase(); const clone = element.cloneNode(false); // Clone without children - + // Create template with placeholders for different element types const template = { tagName: tagName, attributes: {}, editableProperties: [] }; - + // Copy all attributes for (const attr of element.attributes) { template.attributes[attr.name] = attr.value; } - + // Define editable properties and their placeholders switch (tagName) { case 'a': @@ -369,7 +359,7 @@ export class StyleDetectionEngine { } clone.textContent = '{{CONTENT}}'; break; - + case 'img': template.editableProperties = ['src', 'alt', 'title']; template.attributes.src = '{{SRC}}'; @@ -378,12 +368,12 @@ export class StyleDetectionEngine { template.attributes.title = '{{TITLE}}'; } break; - + case 'button': template.editableProperties = ['content']; clone.textContent = '{{CONTENT}}'; break; - + case 'input': template.editableProperties = ['value', 'placeholder']; if (template.attributes.value !== undefined) { @@ -393,17 +383,17 @@ export class StyleDetectionEngine { template.attributes.placeholder = '{{PLACEHOLDER}}'; } break; - + default: // Default: only content is editable template.editableProperties = ['content']; clone.textContent = '{{CONTENT}}'; break; } - + // Store both the structured template and the HTML template for backward compatibility template.html = clone.outerHTML; - + return template; } @@ -417,10 +407,10 @@ export class StyleDetectionEngine { */ createElementFromTemplate(styleInfo, properties) { const element = document.createElement(styleInfo.tagName); - + // Apply classes styleInfo.classes.forEach(cls => element.classList.add(cls)); - + // Apply base attributes (non-editable ones) Object.entries(styleInfo.attributes).forEach(([key, value]) => { // Skip attributes that will be set from properties @@ -428,7 +418,7 @@ export class StyleDetectionEngine { element.setAttribute(key, value); } }); - + // Handle properties - support both object and string (backward compatibility) if (typeof properties === 'string') { // Legacy support: treat as text content @@ -437,7 +427,7 @@ export class StyleDetectionEngine { // New multi-property support this.applyPropertiesToElement(element, properties, styleInfo.tagName); } - + return element; } @@ -455,7 +445,7 @@ export class StyleDetectionEngine { 'input': ['value', 'placeholder'], 'button': [] }; - + return (editableAttributes[tagName] || []).includes(attributeName); } @@ -482,7 +472,7 @@ export class StyleDetectionEngine { element.target = properties.target; } break; - + case 'img': if (properties.src !== undefined) { element.src = properties.src; @@ -494,7 +484,7 @@ export class StyleDetectionEngine { element.title = properties.title; } break; - + case 'button': if (properties.content !== undefined) { element.textContent = properties.content; @@ -506,7 +496,7 @@ export class StyleDetectionEngine { element.disabled = properties.disabled; } break; - + case 'input': if (properties.value !== undefined) { element.value = properties.value; @@ -518,7 +508,7 @@ export class StyleDetectionEngine { element.type = properties.type; } break; - + default: // Default: set text content if (properties.content !== undefined) { @@ -561,7 +551,7 @@ export class StyleDetectionEngine { */ reconstructHTML(structure, styles, updatedProperties = {}) { let html = ''; - + structure.forEach((piece, index) => { if (piece.type === 'text') { // Check if any styles are applied to this text piece @@ -578,7 +568,7 @@ export class StyleDetectionEngine { } else if (piece.type === 'styled') { // Use updated properties or original properties const properties = updatedProperties[index]?.properties || piece.properties; - + if (styles.has(piece.styleId)) { const styleInfo = styles.get(piece.styleId); const styledElement = this.createElementFromTemplate(styleInfo, properties); @@ -590,10 +580,88 @@ export class StyleDetectionEngine { } } }); - + return html; } + /** + * Add smart default formatting options when not already present + * Provides essential formatting (bold, italic, link) if developer hasn't defined them + * + * @param {Map} styleMap - Existing detected styles + * @param {HTMLElement} element - The element being analyzed + */ + addSmartDefaults(styleMap, element) { + // Only add defaults for content elements that benefit from text formatting + const contentTags = new Set(['p', 'div', 'section', 'article', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'td', 'th', 'blockquote']); + + if (!contentTags.has(element.tagName.toLowerCase())) { + return; // Skip for non-content elements + } + + // Check what's already available + const hasStrong = this.hasStyleTag(styleMap, 'strong'); + const hasEm = this.hasStyleTag(styleMap, 'em'); + const hasB = this.hasStyleTag(styleMap, 'b'); + const hasI = this.hasStyleTag(styleMap, 'i'); + const hasA = this.hasStyleTag(styleMap, 'a'); + + // Add Bold if not present (prefer for semantics) + if (!hasStrong && !hasB) { + styleMap.set('default-strong', { + name: 'Bold', + tagName: 'strong', + classes: [], + attributes: {}, + element: null, // Virtual element - no DOM reference + isDefault: true, + description: 'Make text bold for emphasis' + }); + } + + // Add Italic if not present (prefer for semantics) + if (!hasEm && !hasI) { + styleMap.set('default-em', { + name: 'Italic', + tagName: 'em', + classes: [], + attributes: {}, + element: null, + isDefault: true, + description: 'Make text italic for emphasis' + }); + } + + // Add Link if not present + if (!hasA) { + styleMap.set('default-a', { + name: 'Link', + tagName: 'a', + classes: [], + attributes: { href: '' }, + element: null, + isDefault: true, + description: 'Create a hyperlink' + }); + } + } + + /** + * Check if styleMap already contains a specific tag type + * + * @param {Map} styleMap - Style map to check + * @param {string} tagName - Tag name to look for + * @returns {boolean} - True if tag is already present + */ + hasStyleTag(styleMap, tagName) { + for (const [, styleInfo] of styleMap) { + if (styleInfo.tagName === tagName) { + return true; + } + } + return false; + } + /** * Extract plain text from structure while preserving order * @@ -624,12 +692,12 @@ export class StyleDetectionEngine { // Simple approach: if text length matches, assume same structure // More sophisticated approaches could use diff algorithms const originalText = this.extractTextFromStructure(originalStructure); - + if (newText === originalText) { // No changes - return original structure return originalStructure; } - + // For now, create simple text structure // TODO: Implement smarter text-to-structure mapping return [{ @@ -640,4 +708,4 @@ export class StyleDetectionEngine { } // Export singleton instance -export const styleDetectionEngine = new StyleDetectionEngine(); \ No newline at end of file +export const styleDetectionEngine = new StyleDetectionEngine();