diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 76f4289..158936d 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -250,8 +250,9 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) { if siteID == "" { siteID = req.SiteID // fallback to request body } - if siteID == "" { - siteID = "default" // final fallback + if siteID == "" || siteID == "__MISSING_SITE_ID__" { + http.Error(w, "site_id parameter is required and must be configured", http.StatusBadRequest) + return } // Generate content ID using the unified engine diff --git a/internal/content/assets/insertr.css b/internal/content/assets/insertr.css index f8e0900..2dff1d2 100644 --- a/internal/content/assets/insertr.css +++ b/internal/content/assets/insertr.css @@ -442,6 +442,76 @@ body:not(.insertr-edit-mode) .insertr-editing-hover::after { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); } +/* Active/applied formatting state - shows current selection has this formatting */ +.insertr-style-btn.insertr-style-active { + background: var(--insertr-primary); + border-color: var(--insertr-primary); + color: white; + box-shadow: 0 2px 4px rgba(0, 123, 255, 0.3); +} + +.insertr-style-btn.insertr-style-active .insertr-preview-content { + color: white; +} + +.insertr-style-btn.insertr-style-active:hover { + background: var(--insertr-primary-hover); + border-color: var(--insertr-primary-hover); + transform: none; +} + +/* Active state for default style buttons */ +.insertr-style-btn.insertr-default-style.insertr-style-active { + background: var(--insertr-info); + border-color: var(--insertr-info); + color: white; + box-shadow: 0 2px 4px rgba(23, 162, 184, 0.3); +} + +.insertr-style-btn.insertr-default-style.insertr-style-active .insertr-preview-content { + color: white; +} + +/* 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 +549,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/core/api-client.js b/lib/src/core/api-client.js index 9f2b625..3a7d85a 100644 --- a/lib/src/core/api-client.js +++ b/lib/src/core/api-client.js @@ -29,12 +29,11 @@ export class ApiClient { } - async createContent(content, type, htmlMarkup) { + async createContent(content, htmlMarkup) { try { const payload = { html_markup: htmlMarkup, // Always send HTML markup - server extracts ID or generates new one html_content: content, - type: type, file_path: this.getCurrentFilePath() // Always include file path for consistent ID generation }; @@ -49,7 +48,7 @@ export class ApiClient { if (response.ok) { const result = await response.json(); - console.log(`✅ Content created: ${result.id} (${result.type})`); + console.log(`✅ Content created: ${result.id}`); return result; } else { console.warn(`⚠️ Create failed (${response.status}): server will generate ID`); diff --git a/lib/src/core/editor.js b/lib/src/core/editor.js index f537d08..b7a56f5 100644 --- a/lib/src/core/editor.js +++ b/lib/src/core/editor.js @@ -131,10 +131,8 @@ export class InsertrEditor { result = await this.apiClient.updateContent(meta.contentId, contentValue); } else { // Create new content - server handles ID extraction/generation from markup - const contentType = this.determineContentType(meta.element); result = await this.apiClient.createContent( contentValue, - contentType, meta.htmlMarkup // Always send HTML markup - server is smart about ID handling ); } @@ -142,7 +140,6 @@ export class InsertrEditor { if (result) { // Store the backend-generated/confirmed ID in the element meta.element.setAttribute('data-content-id', result.id); - meta.element.setAttribute('data-content-type', result.type); console.log(`✅ Content saved: ${result.id}`, contentValue); } else { console.error('❌ Failed to save content to server'); @@ -157,16 +154,7 @@ export class InsertrEditor { } } - determineContentType(element) { - const tagName = element.tagName.toLowerCase(); - - if (tagName === 'a' || tagName === 'button') { - return 'link'; - } - - // ALL text elements use text content type - return 'text'; - } + handleCancel(meta) { console.log('❌ Edit cancelled:', meta.contentId); diff --git a/lib/src/core/insertr.js b/lib/src/core/insertr.js index 6051bd9..690e39d 100644 --- a/lib/src/core/insertr.js +++ b/lib/src/core/insertr.js @@ -9,29 +9,23 @@ export class InsertrCore { ...options }; } - + // Find all enhanced elements on the page - // Note: Container expansion is handled at build-time by the backend enhancer - // Frontend should only find elements that already have .insertr class findEnhancedElements() { return document.querySelectorAll('.insertr'); } - - // Note: All container expansion logic removed - handled by backend enhancer - // Frontend only works with elements that already have .insertr class - + // Get element metadata getElementMetadata(element) { const existingId = element.getAttribute('data-content-id'); - - // HTML-first approach: no content type needed, just HTML markup for ID generation + return { - contentId: existingId, // null if new content, existing ID if updating + contentId: existingId, element: element, htmlMarkup: element.outerHTML // Server will generate ID from this }; } - + // Get current file path from URL for consistent ID generation getCurrentFilePath() { const path = window.location.pathname; @@ -41,11 +35,10 @@ export class InsertrCore { // Remove leading slash: "/about.html" → "about.html" return path.replace(/^\//, ''); } - + // Get all elements with their metadata - // Note: Container expansion handled by backend - frontend finds enhanced elements only getAllElements() { const elements = this.findEnhancedElements(); return Array.from(elements).map(el => this.getElementMetadata(el)); } -} \ No newline at end of file +} diff --git a/lib/src/index.js b/lib/src/index.js index b094202..46cee49 100644 --- a/lib/src/index.js +++ b/lib/src/index.js @@ -16,7 +16,7 @@ window.Insertr = { editor: null, auth: null, apiClient: null, - + // UI layer (presentation) controlPanel: null, @@ -32,7 +32,7 @@ window.Insertr = { this.auth = new InsertrAuth(options); this.apiClient = new ApiClient(options); this.editor = new InsertrEditor(this.core, this.auth, this.apiClient, options); - + // Initialize UI layer this.controlPanel = new InsertrControlPanel(this.auth, this.editor, this.apiClient, options); @@ -74,7 +74,7 @@ window.Insertr = { link.href = cssPath; link.onload = () => console.log('✅ Insertr CSS loaded'); link.onerror = () => console.warn('⚠️ Failed to load Insertr CSS from:', cssPath); - + document.head.appendChild(link); }, @@ -83,9 +83,6 @@ window.Insertr = { if (this.auth) { this.auth.init(); // Sets up invisible editor gates only } - - // Note: Control panel is NOT created here - only after gate activation - // Note: Editor is NOT started here - only when authentication succeeds }, // Start the full editor system (called when gate is activated) @@ -94,12 +91,12 @@ window.Insertr = { if (this.controlPanel && !this.controlPanel.isInitialized) { this.controlPanel.init(); // Creates unified control panel UI } - + // Start editor functionality if (this.editor && !this.editor.isActive) { this.editor.start(); } - + // Add editing indicators when editor starts if (this.controlPanel) { this.controlPanel.addEditingIndicators(); @@ -138,7 +135,7 @@ function autoInitialize() { // Check for configuration from script data attributes const insertrScript = document.querySelector('script[data-insertr-injected]'); const config = {}; - + if (insertrScript) { config.siteId = insertrScript.getAttribute('data-site-id'); // No fallback - let ApiClient handle missing values config.apiEndpoint = insertrScript.getAttribute('data-api-endpoint') || '/api/content'; @@ -146,7 +143,7 @@ function autoInitialize() { config.mockAuth = config.authProvider === 'mock'; // Set mockAuth based on provider config.debug = insertrScript.getAttribute('data-debug') === 'true'; } - + window.Insertr.init(config); } } diff --git a/lib/src/styles/insertr.css b/lib/src/styles/insertr.css index 02ae0a9..2dff1d2 100644 --- a/lib/src/styles/insertr.css +++ b/lib/src/styles/insertr.css @@ -442,6 +442,36 @@ body:not(.insertr-edit-mode) .insertr-editing-hover::after { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); } +/* Active/applied formatting state - shows current selection has this formatting */ +.insertr-style-btn.insertr-style-active { + background: var(--insertr-primary); + border-color: var(--insertr-primary); + color: white; + box-shadow: 0 2px 4px rgba(0, 123, 255, 0.3); +} + +.insertr-style-btn.insertr-style-active .insertr-preview-content { + color: white; +} + +.insertr-style-btn.insertr-style-active:hover { + background: var(--insertr-primary-hover); + border-color: var(--insertr-primary-hover); + transform: none; +} + +/* Active state for default style buttons */ +.insertr-style-btn.insertr-default-style.insertr-style-active { + background: var(--insertr-info); + border-color: var(--insertr-info); + color: white; + box-shadow: 0 2px 4px rgba(23, 162, 184, 0.3); +} + +.insertr-style-btn.insertr-default-style.insertr-style-active .insertr-preview-content { + color: white; +} + /* Default formatting style buttons */ .insertr-style-btn.insertr-default-style { border-color: var(--insertr-info); diff --git a/lib/src/ui/style-aware-editor.js b/lib/src/ui/style-aware-editor.js index 295a8ca..2d6b40e 100644 --- a/lib/src/ui/style-aware-editor.js +++ b/lib/src/ui/style-aware-editor.js @@ -174,26 +174,25 @@ export class StyleAwareEditor { /** * 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 {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} - The link configuration form + * @returns {HTMLElement} - Link configuration form */ - createLinkConfigurationForm(options = {}) { + createLinkConfiguration(options = {}) { const { text = '', url = '', target = '', - mode = 'direct', - showTextField = true, + showText = true, + title = 'Configure Link', onSave = () => {}, onCancel = () => {}, onRemove = null @@ -204,28 +203,19 @@ export class StyleAwareEditor { form.className = 'insertr-direct-editor insertr-link-editor'; // Create title - const title = document.createElement('h3'); - title.className = 'insertr-editor-title'; - title.textContent = url ? 'Edit Link' : 'Add Link'; - title.style.cssText = ` + 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(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); - } + form.appendChild(titleElement); - // Text field (only show if requested) - if (showTextField) { + // Text field (if needed) + if (showText) { const textGroup = document.createElement('div'); textGroup.className = 'insertr-form-group'; textGroup.innerHTML = ` @@ -259,131 +249,85 @@ export class StyleAwareEditor { `; 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 + // Add real-time validation this.addLinkValidation(form); - - // Event handlers - saveBtn.addEventListener('click', (e) => { + + // Add save/cancel handlers + form.addEventListener('submit', (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; + const formData = this.extractLinkFormData(form); + if (formData) { + onSave(formData); } - - 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(); + + // Store callbacks for later use + form._onSave = () => { + const formData = this.extractLinkFormData(form); + if (formData) { + onSave(formData); } - }, 100); + }; + 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 form = this.createLinkConfigurationForm({ + const linkConfig = this.createLinkConfiguration({ 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(); + showText: true, + title: 'Edit Link', + onSave: (data) => { + // Data will be extracted in extractDirectEditorContent } }); - this.contentEditor = form; - this.editorContainer.appendChild(form); + 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); } /** @@ -524,6 +468,9 @@ export class StyleAwareEditor { 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 @@ -533,6 +480,7 @@ export class StyleAwareEditor { 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 @@ -547,26 +495,144 @@ export class StyleAwareEditor { this.toolbar = toolbar; this.editorContainer.insertBefore(toolbar, this.contentEditor); + + // Set up selection change listener to update button states + this.setupSelectionChangeListener(); } /** - * Create button for applying detected style - * - * @param {string} styleId - Style identifier - * @param {Object} styleInfo - Style information - * @returns {HTMLElement} - Style button + * Set up listener for selection changes to update button states */ - createStyleButton(styleId, styleInfo) { + 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 = `Apply ${styleInfo.name} style`; - button.dataset.styleId = styleId; + button.title = title; + + if (styleId) { + button.dataset.styleId = styleId; + } // Create preview content container const previewContent = document.createElement('span'); previewContent.className = 'insertr-preview-content'; - previewContent.textContent = styleInfo.name; + previewContent.textContent = text; + + // Apply type-specific styling + switch (type) { + case 'style': + this.applyStyleButtonStyling(button, previewContent, styleInfo); + break; + case 'link': + this.applyLinkButtonStyling(button, previewContent); + break; + case 'action': + default: + // Default action button styling + break; + } + + // Add the preview content to the button + button.appendChild(previewContent); + + // Add click handler + button.addEventListener('click', (e) => { + e.preventDefault(); + onClick(e); + }); + + return button; + } + + /** + * Apply styling for style buttons + */ + applyStyleButtonStyling(button, previewContent, styleInfo) { + if (!styleInfo) return; // Apply styling based on whether this is a detected style or default if (styleInfo.isDefault) { @@ -598,17 +664,33 @@ 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); + /** + * Apply styling for link buttons + */ + applyLinkButtonStyling(button, previewContent) { + // Add link-specific styling + previewContent.style.textDecoration = 'underline'; + previewContent.style.color = '#0066cc'; + } - // Add click handler for style application - button.addEventListener('click', (e) => { - e.preventDefault(); - this.applyStyle(styleId); + /** + * 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 }); - - return button; } /** @@ -617,29 +699,12 @@ export class StyleAwareEditor { * @returns {HTMLElement} - Link button */ createLinkButton() { - const button = document.createElement('button'); - button.type = 'button'; - 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 this.createFormattingButton({ + type: 'link', + title: 'Add/Edit Link', + text: '🔗 Link', + onClick: () => this.openLinkPopup() }); - - return button; } /** @@ -710,7 +775,7 @@ export class StyleAwareEditor { } /** - * Apply style to current selection in rich text editor + * Apply style to current selection with intelligent toggle and merging * * @param {Object} styleInfo - Style information */ @@ -721,25 +786,711 @@ export class StyleAwareEditor { } const range = selection.getRangeAt(0); - const selectedText = range.toString(); - - if (selectedText) { - // Create styled element - const styledElement = this.styleEngine.createElementFromTemplate( - styleInfo, - { content: selectedText } - ); - - // Replace selection with styled element - range.deleteContents(); - range.insertNode(styledElement); - - // Clear selection - selection.removeAllRanges(); - - // Trigger change event - this.handleChange(); + + // 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); + } + }); } /** @@ -804,11 +1555,18 @@ export class StyleAwareEditor { */ extractDirectEditorContent() { const tagName = this.element.tagName.toLowerCase(); - - // Note: Link editing is now handled directly in createLinkConfigurationForm - // This method only handles button and image elements - - if (tagName === 'button') { + + 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 { @@ -933,48 +1691,92 @@ export class StyleAwareEditor { // Create popup container const popup = document.createElement('div'); popup.className = 'insertr-modal-container'; - popup.style.maxWidth = '600px'; // Wider to accommodate polished form + popup.style.maxWidth = '400px'; - // Create unified form using shared component - const form = this.createLinkConfigurationForm({ - text: text, + // Create unified link configuration form + const linkForm = this.createLinkConfiguration({ 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); + 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 ? () => { - // Call onSave with empty string to indicate removal - onSave('', ''); + onSave('', ''); // Empty URL removes the link document.body.removeChild(overlay); } : null }); - popup.appendChild(form); - overlay.appendChild(popup); - document.body.appendChild(overlay); + // 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); - // Close on overlay click overlay.addEventListener('click', (e) => { if (e.target === overlay) { - document.body.removeChild(overlay); + linkForm._onCancel(); } }); - // Handle escape key - const handleEscape = (e) => { - if (e.key === 'Escape') { - document.body.removeChild(overlay); - document.removeEventListener('keydown', handleEscape); + // 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(); } - }; - document.addEventListener('keydown', handleEscape); + }, 100); } /** @@ -1041,44 +1843,53 @@ export class StyleAwareEditor { 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'); + // 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 - 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'); - } - }); + // 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'); @@ -1112,6 +1923,12 @@ export class StyleAwareEditor { * 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); } @@ -1120,5 +1937,6 @@ export class StyleAwareEditor { this.editorContainer = null; this.contentEditor = null; this.toolbar = null; + this.styleButtons = null; } }