From 2346eea87431a19b303d469e2545af047ef94240 Mon Sep 17 00:00:00 2001 From: Joakim Date: Sun, 7 Sep 2025 19:15:10 +0200 Subject: [PATCH] feat: add live preview system and enhance dev workflow - Implement debounced live preview in modal editing (500ms) - Add LivePreviewManager class with element tracking and restoration - Enhance modal sizing for comfortable 60-80 character editing - Add auto-copy plugin to rollup config for seamless development - Update dev command to automatically sync changes to demo-site The live preview system provides real-time visual feedback while typing in modals, showing changes in context without saving. Enhanced dev workflow eliminates manual build steps, enabling instant iteration during development. --- lib/rollup.config.js | 19 +- lib/src/ui/form-renderer.js | 346 ++++++++++++++++++++++++++++++++---- 2 files changed, 326 insertions(+), 39 deletions(-) diff --git a/lib/rollup.config.js b/lib/rollup.config.js index 457143b..e252314 100644 --- a/lib/rollup.config.js +++ b/lib/rollup.config.js @@ -1,5 +1,21 @@ import { nodeResolve } from '@rollup/plugin-node-resolve'; import terser from '@rollup/plugin-terser'; +import { execSync } from 'child_process'; + +// Simple copy plugin to auto-copy to demo-site during development +function copyToDemo() { + return { + name: 'copy-to-demo', + writeBundle() { + try { + execSync('cp dist/insertr.js ../demo-site/insertr.js'); + console.log('📄 Copied to demo-site/insertr.js'); + } catch (error) { + console.warn('⚠️ Failed to copy to demo-site:', error.message); + } + } + }; +} export default [ // Development build @@ -11,7 +27,8 @@ export default [ name: 'Insertr' }, plugins: [ - nodeResolve() + nodeResolve(), + copyToDemo() ] }, // Production build (minified) diff --git a/lib/src/ui/form-renderer.js b/lib/src/ui/form-renderer.js index ddf9845..20d04ae 100644 --- a/lib/src/ui/form-renderer.js +++ b/lib/src/ui/form-renderer.js @@ -1,10 +1,154 @@ /** - * InsertrFormRenderer - Professional modal editing forms - * Ported from prototype with modern ES6+ architecture + * 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(); } @@ -21,29 +165,32 @@ export class InsertrFormRenderer { 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 + + // Position form with enhanced sizing this.positionForm(element, overlay); - - // Setup event handlers - this.setupFormHandlers(form, overlay, { onSave, onCancel }); - + + // 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; } @@ -51,6 +198,11 @@ export class InsertrFormRenderer { * 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; @@ -63,7 +215,7 @@ export class InsertrFormRenderer { 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...' }, @@ -84,7 +236,7 @@ export class InsertrFormRenderer { 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 }; @@ -99,9 +251,9 @@ export class InsertrFormRenderer { 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) { @@ -111,7 +263,7 @@ export class InsertrFormRenderer { } else { formHTML += this.createTextField(config, currentContent); } - + // Form buttons formHTML += `
@@ -119,7 +271,7 @@ export class InsertrFormRenderer {
`; - + form.innerHTML = formHTML; return form; } @@ -146,7 +298,7 @@ export class InsertrFormRenderer { createLinkField(config, currentContent) { const linkText = typeof currentContent === 'object' ? currentContent.text || '' : currentContent; const linkUrl = typeof currentContent === 'object' ? currentContent.url || '' : ''; - + return `
@@ -210,23 +362,38 @@ export class InsertrFormRenderer { positionForm(element, overlay) { const rect = element.getBoundingClientRect(); const form = overlay.querySelector('.insertr-edit-form'); - - // Calculate optimal width (responsive) + + // Calculate optimal width for comfortable editing (60-80 characters) const viewportWidth = window.innerWidth; let formWidth; - + if (viewportWidth < 768) { - formWidth = Math.min(viewportWidth - 40, 350); + // Mobile: prioritize usability over character count + formWidth = Math.min(viewportWidth - 40, 500); } else { - formWidth = Math.min(Math.max(rect.width, 300), 500); + // 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; - const left = Math.max(20, rect.left + window.scrollX); - + + // 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`; @@ -236,54 +403,107 @@ export class InsertrFormRenderer { /** * Setup form event handlers */ - setupFormHandlers(form, overlay, { onSave, onCancel }) { + 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; @@ -292,7 +512,7 @@ export class InsertrFormRenderer { // Text or textarea field data.text = contentInput.value; } - + return data; } @@ -433,11 +653,61 @@ export class InsertrFormRenderer { 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); } -} \ No newline at end of file +}