Files
insertr/lib/src/ui/form-renderer.js
Joakim 2346eea874 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.
2025-09-07 19:15:10 +02:00

714 lines
24 KiB
JavaScript

/**
* 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 = `<div class="insertr-form-header">${config.label}</div>`;
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 += `
<div class="insertr-form-actions">
<button type="button" class="insertr-btn-save">Save</button>
<button type="button" class="insertr-btn-cancel">Cancel</button>
</div>
`;
form.innerHTML = formHTML;
return form;
}
/**
* Create markdown field with preview
*/
createMarkdownField(config, currentContent) {
return `
<div class="insertr-form-group">
<textarea class="insertr-form-textarea insertr-markdown-editor" name="content"
rows="${config.rows || 8}"
placeholder="${config.placeholder}">${this.escapeHtml(currentContent)}</textarea>
<div class="insertr-form-help">
Supports Markdown formatting (bold, italic, links, etc.)
</div>
</div>
`;
}
/**
* Create link field (text + URL)
*/
createLinkField(config, currentContent) {
const linkText = typeof currentContent === 'object' ? currentContent.text || '' : currentContent;
const linkUrl = typeof currentContent === 'object' ? currentContent.url || '' : '';
return `
<div class="insertr-form-group">
<label class="insertr-form-label">Link Text:</label>
<input type="text" class="insertr-form-input" name="text"
value="${this.escapeHtml(linkText)}"
placeholder="${config.placeholder}"
maxlength="${config.maxLength || 200}">
</div>
<div class="insertr-form-group">
<label class="insertr-form-label">Link URL:</label>
<input type="url" class="insertr-form-input" name="url"
value="${this.escapeHtml(linkUrl)}"
placeholder="https://example.com">
</div>
`;
}
/**
* Create textarea field
*/
createTextareaField(config, currentContent) {
const content = typeof currentContent === 'object' ? currentContent.text || '' : currentContent;
return `
<div class="insertr-form-group">
<textarea class="insertr-form-textarea" name="content"
rows="${config.rows || 3}"
placeholder="${config.placeholder}"
maxlength="${config.maxLength || 1000}">${this.escapeHtml(content)}</textarea>
</div>
`;
}
/**
* Create text input field
*/
createTextField(config, currentContent) {
const content = typeof currentContent === 'object' ? currentContent.text || '' : currentContent;
return `
<div class="insertr-form-group">
<input type="text" class="insertr-form-input" name="content"
value="${this.escapeHtml(content)}"
placeholder="${config.placeholder}"
maxlength="${config.maxLength || 200}">
</div>
`;
}
/**
* 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);
}
}