/** * LivePreviewManager - Handles debounced live preview updates */ class LivePreviewManager { constructor() { this.previewTimeouts = new Map(); this.activeElement = null; this.originalContent = null; this.originalStyles = null; } schedulePreview(element, newValue, elementType) { const elementId = this.getElementId(element); // Clear existing timeout if (this.previewTimeouts.has(elementId)) { clearTimeout(this.previewTimeouts.get(elementId)); } // Schedule new preview update with 500ms debounce const timeoutId = setTimeout(() => { this.updatePreview(element, newValue, elementType); }, 500); this.previewTimeouts.set(elementId, timeoutId); } updatePreview(element, newValue, elementType) { // Store original content if first preview if (!this.originalContent && this.activeElement === element) { this.originalContent = this.extractOriginalContent(element, elementType); } // Apply preview styling and content this.applyPreviewContent(element, newValue, elementType); } extractOriginalContent(element, elementType) { switch (elementType) { case 'link': return { text: element.textContent, url: element.href }; default: return element.textContent; } } applyPreviewContent(element, newValue, elementType) { // Add preview indicator element.classList.add('insertr-preview-active'); // Update content based on element type switch (elementType) { case 'text': case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': case 'span': case 'button': if (newValue && newValue.trim()) { element.textContent = newValue; } break; case 'textarea': case 'p': if (newValue && newValue.trim()) { element.textContent = newValue; } break; case 'link': if (typeof newValue === 'object') { if (newValue.text !== undefined && newValue.text.trim()) { element.textContent = newValue.text; } if (newValue.url !== undefined && newValue.url.trim()) { element.href = newValue.url; } } else if (newValue && newValue.trim()) { element.textContent = newValue; } break; case 'markdown': // For markdown, show raw text preview if (newValue && newValue.trim()) { element.textContent = newValue; } break; } } clearPreview(element) { if (!element) return; const elementId = this.getElementId(element); // Clear any pending preview if (this.previewTimeouts.has(elementId)) { clearTimeout(this.previewTimeouts.get(elementId)); this.previewTimeouts.delete(elementId); } // Restore original content if (this.originalContent && element === this.activeElement) { this.restoreOriginalContent(element); } // Remove preview styling element.classList.remove('insertr-preview-active'); this.activeElement = null; this.originalContent = null; } restoreOriginalContent(element) { if (!this.originalContent) return; if (typeof this.originalContent === 'object') { // Link element element.textContent = this.originalContent.text; if (this.originalContent.url) { element.href = this.originalContent.url; } } else { // Text element element.textContent = this.originalContent; } } getElementId(element) { // Create unique ID for element tracking if (!element._insertrId) { element._insertrId = 'insertr_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); } return element._insertrId; } setActiveElement(element) { this.activeElement = element; this.originalContent = null; } } /** * InsertrFormRenderer - Professional modal editing forms with live preview * Enhanced with debounced live preview and comfortable input sizing */ export class InsertrFormRenderer { constructor() { this.currentOverlay = null; this.previewManager = new LivePreviewManager(); this.setupStyles(); } /** * Create and show edit form for content element * @param {Object} meta - Element metadata {element, contentId, contentType} * @param {string} currentContent - Current content value * @param {Function} onSave - Save callback * @param {Function} onCancel - Cancel callback */ showEditForm(meta, currentContent, onSave, onCancel) { // Close any existing form this.closeForm(); const { element, contentId, contentType } = meta; const config = this.getFieldConfig(element, contentType); // Initialize preview manager for this element this.previewManager.setActiveElement(element); // Create form const form = this.createEditForm(contentId, config, currentContent); // Create overlay with backdrop const overlay = this.createOverlay(form); // Position form with enhanced sizing this.positionForm(element, overlay); // Setup event handlers with live preview this.setupFormHandlers(form, overlay, element, config, { onSave, onCancel }); // Show form document.body.appendChild(overlay); this.currentOverlay = overlay; // Focus first input const firstInput = form.querySelector('input, textarea'); if (firstInput) { setTimeout(() => firstInput.focus(), 100); } return overlay; } /** * Close current form */ closeForm() { // Clear any active previews if (this.previewManager.activeElement) { this.previewManager.clearPreview(this.previewManager.activeElement); } if (this.currentOverlay) { this.currentOverlay.remove(); this.currentOverlay = null; } } /** * Generate field configuration based on element */ getFieldConfig(element, contentType) { const tagName = element.tagName.toLowerCase(); const classList = Array.from(element.classList); // Default configurations based on element type const configs = { h1: { type: 'text', label: 'Headline', maxLength: 60, placeholder: 'Enter headline...' }, h2: { type: 'text', label: 'Subheading', maxLength: 80, placeholder: 'Enter subheading...' }, h3: { type: 'text', label: 'Section Title', maxLength: 100, placeholder: 'Enter title...' }, h4: { type: 'text', label: 'Title', maxLength: 100, placeholder: 'Enter title...' }, h5: { type: 'text', label: 'Title', maxLength: 100, placeholder: 'Enter title...' }, h6: { type: 'text', label: 'Title', maxLength: 100, placeholder: 'Enter title...' }, p: { type: 'textarea', label: 'Paragraph', rows: 3, placeholder: 'Enter paragraph text...' }, a: { type: 'link', label: 'Link', placeholder: 'Enter link text...', includeUrl: true }, span: { type: 'text', label: 'Text', placeholder: 'Enter text...' }, button: { type: 'text', label: 'Button Text', placeholder: 'Enter button text...' }, }; let config = configs[tagName] || { type: 'text', label: 'Text', placeholder: 'Enter text...' }; // CSS class enhancements if (classList.includes('lead')) { config = { ...config, label: 'Lead Paragraph', rows: 4, placeholder: 'Enter lead paragraph...' }; } // Override with contentType from CLI if specified if (contentType === 'markdown') { config = { ...config, type: 'markdown', label: 'Markdown Content', rows: 8 }; } return config; } /** * Create form HTML structure */ createEditForm(contentId, config, currentContent) { const form = document.createElement('div'); form.className = 'insertr-edit-form'; let formHTML = `
${config.label}
`; if (config.type === 'markdown') { formHTML += this.createMarkdownField(config, currentContent); } else if (config.type === 'link' && config.includeUrl) { formHTML += this.createLinkField(config, currentContent); } else if (config.type === 'textarea') { formHTML += this.createTextareaField(config, currentContent); } else { formHTML += this.createTextField(config, currentContent); } // Form buttons formHTML += `
`; form.innerHTML = formHTML; return form; } /** * Create markdown field with preview */ createMarkdownField(config, currentContent) { return `
Supports Markdown formatting (bold, italic, links, etc.)
`; } /** * Create link field (text + URL) */ createLinkField(config, currentContent) { const linkText = typeof currentContent === 'object' ? currentContent.text || '' : currentContent; const linkUrl = typeof currentContent === 'object' ? currentContent.url || '' : ''; return `
`; } /** * Create textarea field */ createTextareaField(config, currentContent) { const content = typeof currentContent === 'object' ? currentContent.text || '' : currentContent; return `
`; } /** * Create text input field */ createTextField(config, currentContent) { const content = typeof currentContent === 'object' ? currentContent.text || '' : currentContent; return `
`; } /** * Create overlay with backdrop */ createOverlay(form) { const overlay = document.createElement('div'); overlay.className = 'insertr-form-overlay'; overlay.appendChild(form); return overlay; } /** * Position form relative to element */ positionForm(element, overlay) { const rect = element.getBoundingClientRect(); const form = overlay.querySelector('.insertr-edit-form'); // Calculate optimal width for comfortable editing (60-80 characters) const viewportWidth = window.innerWidth; let formWidth; if (viewportWidth < 768) { // Mobile: prioritize usability over character count formWidth = Math.min(viewportWidth - 40, 500); } else { // Desktop: ensure comfortable 60-80 character editing const minComfortableWidth = 600; // ~70 characters at 1rem const maxWidth = Math.min(viewportWidth * 0.9, 800); // Max 800px or 90% viewport const elementWidth = rect.width; // Use larger of: comfortable width, 1.5x element width, but cap at maxWidth formWidth = Math.max( minComfortableWidth, Math.min(elementWidth * 1.5, maxWidth) ); } form.style.width = `${formWidth}px`; // Position below element with some spacing const top = rect.bottom + window.scrollY + 10; // Center form relative to element, but keep within viewport const centerLeft = rect.left + window.scrollX + (rect.width / 2) - (formWidth / 2); const minLeft = 20; const maxLeft = window.innerWidth - formWidth - 20; const left = Math.max(minLeft, Math.min(centerLeft, maxLeft)); overlay.style.position = 'absolute'; overlay.style.top = `${top}px`; overlay.style.left = `${left}px`; overlay.style.zIndex = '10000'; } /** * Setup form event handlers */ setupFormHandlers(form, overlay, element, config, { onSave, onCancel }) { const saveBtn = form.querySelector('.insertr-btn-save'); const cancelBtn = form.querySelector('.insertr-btn-cancel'); const elementType = this.getElementType(element, config); // Setup live preview for input changes this.setupLivePreview(form, element, elementType); if (saveBtn) { saveBtn.addEventListener('click', () => { // Clear preview before saving (makes changes permanent) this.previewManager.clearPreview(element); const formData = this.extractFormData(form); onSave(formData); this.closeForm(); }); } if (cancelBtn) { cancelBtn.addEventListener('click', () => { // Clear preview to restore original content this.previewManager.clearPreview(element); onCancel(); this.closeForm(); }); } // ESC key to cancel const keyHandler = (e) => { if (e.key === 'Escape') { this.previewManager.clearPreview(element); onCancel(); this.closeForm(); document.removeEventListener('keydown', keyHandler); } }; document.addEventListener('keydown', keyHandler); // Click outside to cancel overlay.addEventListener('click', (e) => { if (e.target === overlay) { this.previewManager.clearPreview(element); onCancel(); this.closeForm(); } }); } setupLivePreview(form, element, elementType) { // Get all input elements that should trigger preview updates const inputs = form.querySelectorAll('input, textarea'); inputs.forEach(input => { input.addEventListener('input', () => { const newValue = this.extractInputValue(form, elementType); this.previewManager.schedulePreview(element, newValue, elementType); }); }); } extractInputValue(form, elementType) { // Extract current form values for preview const textInput = form.querySelector('input[name="text"]'); const urlInput = form.querySelector('input[name="url"]'); const contentInput = form.querySelector('input[name="content"], textarea[name="content"]'); if (textInput && urlInput) { // Link field return { text: textInput.value, url: urlInput.value }; } else if (contentInput) { // Text or textarea field return contentInput.value; } return ''; } getElementType(element, config) { // Determine element type for preview handling if (config.type === 'link') return 'link'; if (config.type === 'markdown') return 'markdown'; if (config.type === 'textarea') return 'textarea'; const tagName = element.tagName.toLowerCase(); return tagName === 'p' ? 'p' : 'text'; } /** * Extract form data */ extractFormData(form) { const data = {}; // Handle different field types const textInput = form.querySelector('input[name="text"]'); const urlInput = form.querySelector('input[name="url"]'); const contentInput = form.querySelector('input[name="content"], textarea[name="content"]'); if (textInput && urlInput) { // Link field data.text = textInput.value; data.url = urlInput.value; } else if (contentInput) { // Text or textarea field data.text = contentInput.value; } return data; } /** * Escape HTML to prevent XSS */ escapeHtml(text) { if (typeof text !== 'string') return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Setup form styles */ setupStyles() { const styles = ` .insertr-form-overlay { position: absolute; z-index: 10000; } .insertr-edit-form { background: white; border: 2px solid #007cba; border-radius: 8px; padding: 1rem; box-shadow: 0 8px 25px rgba(0,0,0,0.15); width: 100%; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .insertr-form-header { font-weight: 600; color: #1f2937; margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid #e5e7eb; font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.5px; } .insertr-form-group { margin-bottom: 1rem; } .insertr-form-group:last-child { margin-bottom: 0; } .insertr-form-label { display: block; font-weight: 600; color: #374151; margin-bottom: 0.5rem; font-size: 0.875rem; } .insertr-form-input, .insertr-form-textarea { width: 100%; padding: 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; font-family: inherit; font-size: 1rem; transition: border-color 0.2s, box-shadow 0.2s; box-sizing: border-box; } .insertr-form-input:focus, .insertr-form-textarea:focus { outline: none; border-color: #007cba; box-shadow: 0 0 0 3px rgba(0, 124, 186, 0.1); } .insertr-form-textarea { min-height: 120px; resize: vertical; font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; } .insertr-markdown-editor { min-height: 200px; font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; font-size: 0.9rem; line-height: 1.5; background-color: #f8fafc; } .insertr-form-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #e5e7eb; } .insertr-btn-save { background: #10b981; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; font-weight: 500; cursor: pointer; transition: background-color 0.2s; font-size: 0.875rem; } .insertr-btn-save:hover { background: #059669; } .insertr-btn-cancel { background: #6b7280; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; font-weight: 500; cursor: pointer; transition: background-color 0.2s; font-size: 0.875rem; } .insertr-btn-cancel:hover { background: #4b5563; } .insertr-form-help { font-size: 0.75rem; color: #6b7280; margin-top: 0.25rem; } /* Live Preview Styles */ .insertr-preview-active { position: relative; background: rgba(0, 124, 186, 0.05) !important; outline: 2px solid #007cba !important; outline-offset: 2px; transition: all 0.3s ease; } .insertr-preview-active::after { content: "Preview"; position: absolute; top: -25px; left: 0; background: #007cba; color: white; padding: 2px 8px; border-radius: 3px; font-size: 0.75rem; font-weight: 500; z-index: 10001; white-space: nowrap; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } /* Enhanced modal sizing for comfortable editing */ .insertr-edit-form { min-width: 600px; /* Ensures ~70 character width */ max-width: 800px; } @media (max-width: 768px) { .insertr-edit-form { min-width: 90vw; max-width: 90vw; } .insertr-preview-active::after { top: -20px; font-size: 0.7rem; padding: 1px 6px; } } /* Enhanced input styling for comfortable editing */ .insertr-form-input { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace; letter-spacing: 0.02em; } `; const styleSheet = document.createElement('style'); styleSheet.type = 'text/css'; styleSheet.innerHTML = styles; document.head.appendChild(styleSheet); } }