import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js'; import type { IBlock } from '../../wysiwyg.types.js'; import { cssManager } from '@design.estate/dees-element'; /** * HTMLBlockHandler - Handles raw HTML content with preview/edit toggle * * Features: * - Live HTML preview (sandboxed) * - Edit/preview mode toggle * - Syntax highlighting in edit mode * - HTML validation hints * - Auto-save on mode switch */ export class HtmlBlockHandler extends BaseBlockHandler { type = 'html'; render(block: IBlock, isSelected: boolean): string { const isEditMode = block.metadata?.isEditMode ?? true; const content = block.content || ''; return `
</>
HTML
${isEditMode ? this.renderEditor(content) : this.renderPreview(content)}
`; } private renderEditor(content: string): string { return ` `; } private renderPreview(content: string): string { return `
${content || '
No content to preview
'}
`; } setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void { const container = element.querySelector('.html-block-container') as HTMLElement; const toggleBtn = element.querySelector('.html-toggle-mode') as HTMLButtonElement; if (!container || !toggleBtn) { console.error('HtmlBlockHandler: Could not find required elements'); return; } // Initialize metadata if (!block.metadata) block.metadata = {}; if (block.metadata.isEditMode === undefined) block.metadata.isEditMode = true; // Toggle mode button toggleBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); // Save current content if in edit mode if (block.metadata.isEditMode) { const editor = container.querySelector('.html-editor') as HTMLTextAreaElement; if (editor) { block.content = editor.value; } } // Toggle mode block.metadata.isEditMode = !block.metadata.isEditMode; // Request UI update handlers.onRequestUpdate?.(); }); // Setup based on mode if (block.metadata.isEditMode) { this.setupEditor(element, block, handlers); } else { this.setupPreview(element, block, handlers); } } private setupEditor(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void { const editor = element.querySelector('.html-editor') as HTMLTextAreaElement; if (!editor) return; // Focus handling editor.addEventListener('focus', () => handlers.onFocus()); editor.addEventListener('blur', () => handlers.onBlur()); // Content changes editor.addEventListener('input', () => { block.content = editor.value; this.validateHtml(editor.value); }); // Keyboard shortcuts editor.addEventListener('keydown', (e) => { // Tab handling for indentation if (e.key === 'Tab') { e.preventDefault(); const start = editor.selectionStart; const end = editor.selectionEnd; const value = editor.value; if (e.shiftKey) { // Unindent const beforeCursor = value.substring(0, start); const lastNewline = beforeCursor.lastIndexOf('\n'); const lineStart = lastNewline + 1; const lineContent = value.substring(lineStart, start); if (lineContent.startsWith(' ')) { editor.value = value.substring(0, lineStart) + lineContent.substring(2) + value.substring(start); editor.selectionStart = editor.selectionEnd = start - 2; } } else { // Indent editor.value = value.substring(0, start) + ' ' + value.substring(end); editor.selectionStart = editor.selectionEnd = start + 2; } block.content = editor.value; return; } // Auto-close tags (Ctrl/Cmd + /) if ((e.ctrlKey || e.metaKey) && e.key === '/') { e.preventDefault(); this.autoCloseTag(editor); block.content = editor.value; return; } // Pass other key events to handlers handlers.onKeyDown(e); }); // Auto-resize this.autoResize(editor); editor.addEventListener('input', () => this.autoResize(editor)); } private setupPreview(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void { const container = element.querySelector('.html-block-container') as HTMLElement; const preview = element.querySelector('.html-preview') as HTMLElement; if (!container || !preview) return; // Make preview focusable preview.setAttribute('tabindex', '0'); // Focus handling preview.addEventListener('focus', () => handlers.onFocus()); preview.addEventListener('blur', () => handlers.onBlur()); // Keyboard navigation preview.addEventListener('keydown', (e) => { // Switch to edit mode on Enter if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); block.metadata.isEditMode = true; handlers.onRequestUpdate?.(); return; } handlers.onKeyDown(e); }); // Sandbox styles and scripts in preview this.sandboxContent(preview); } private autoCloseTag(editor: HTMLTextAreaElement): void { const cursorPos = editor.selectionStart; const text = editor.value; // Find the opening tag let tagStart = cursorPos; while (tagStart > 0 && text[tagStart - 1] !== '<') { tagStart--; } if (tagStart > 0) { const tagContent = text.substring(tagStart, cursorPos); const tagMatch = tagContent.match(/^(\w+)/); if (tagMatch) { const tagName = tagMatch[1]; const closingTag = ``; // Insert closing tag editor.value = text.substring(0, cursorPos) + '>' + closingTag + text.substring(cursorPos); editor.selectionStart = editor.selectionEnd = cursorPos + 1; } } } private autoResize(editor: HTMLTextAreaElement): void { editor.style.height = 'auto'; editor.style.height = editor.scrollHeight + 'px'; } private validateHtml(html: string): boolean { // Basic HTML validation const openTags: string[] = []; const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g; let match; while ((match = tagRegex.exec(html)) !== null) { const isClosing = match[0].startsWith('')) { // Not a self-closing tag openTags.push(tagName); } } if (openTags.length > 0) { console.warn(`Unclosed tags: ${openTags.join(', ')}`); return false; } return true; } private sandboxContent(preview: HTMLElement): void { // Remove any script tags const scripts = preview.querySelectorAll('script'); scripts.forEach(script => script.remove()); // Remove event handlers const allElements = preview.querySelectorAll('*'); allElements.forEach(el => { // Remove all on* attributes Array.from(el.attributes).forEach(attr => { if (attr.name.startsWith('on')) { el.removeAttribute(attr.name); } }); }); // Prevent forms from submitting const forms = preview.querySelectorAll('form'); forms.forEach(form => { form.addEventListener('submit', (e) => { e.preventDefault(); e.stopPropagation(); }); }); } private escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } getContent(element: HTMLElement): string { const editor = element.querySelector('.html-editor') as HTMLTextAreaElement; if (editor) { return editor.value; } // If in preview mode, return the stored content const container = element.querySelector('.html-block-container'); const blockId = container?.getAttribute('data-block-id'); // In real implementation, would need access to block data return ''; } setContent(element: HTMLElement, content: string): void { const editor = element.querySelector('.html-editor') as HTMLTextAreaElement; if (editor) { editor.value = content; this.autoResize(editor); } } getCursorPosition(element: HTMLElement): number | null { const editor = element.querySelector('.html-editor') as HTMLTextAreaElement; return editor ? editor.selectionStart : null; } setCursorToStart(element: HTMLElement): void { const editor = element.querySelector('.html-editor') as HTMLTextAreaElement; if (editor) { editor.selectionStart = editor.selectionEnd = 0; editor.focus(); } else { this.focus(element); } } setCursorToEnd(element: HTMLElement): void { const editor = element.querySelector('.html-editor') as HTMLTextAreaElement; if (editor) { const length = editor.value.length; editor.selectionStart = editor.selectionEnd = length; editor.focus(); } else { this.focus(element); } } focus(element: HTMLElement): void { const editor = element.querySelector('.html-editor') as HTMLTextAreaElement; if (editor) { editor.focus(); } else { const preview = element.querySelector('.html-preview') as HTMLElement; preview?.focus(); } } focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void { const editor = element.querySelector('.html-editor') as HTMLTextAreaElement; if (editor) { if (position === 'start') { this.setCursorToStart(element); } else if (position === 'end') { this.setCursorToEnd(element); } else if (typeof position === 'number') { editor.selectionStart = editor.selectionEnd = position; editor.focus(); } } else { this.focus(element); } } getSplitContent(element: HTMLElement): { before: string; after: string } | null { const editor = element.querySelector('.html-editor') as HTMLTextAreaElement; if (!editor) return null; const cursorPos = editor.selectionStart; return { before: editor.value.substring(0, cursorPos), after: editor.value.substring(cursorPos) }; } getStyles(): string { return ` /* HTML Block Container */ .html-block-container { position: relative; margin: 12px 0; border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')}; border-radius: 6px; overflow: hidden; transition: all 0.15s ease; background: ${cssManager.bdTheme('#ffffff', '#111827')}; } .html-block-container.selected { border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')}; } /* Header */ .html-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')}; background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')}; } .html-icon { font-size: 14px; font-weight: 600; opacity: 0.8; font-family: 'Monaco', 'Consolas', 'Courier New', monospace; } .html-title { flex: 1; font-size: 13px; font-weight: 500; color: ${cssManager.bdTheme('#374151', '#e5e7eb')}; } .html-toggle-mode { padding: 4px 8px; background: transparent; border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')}; border-radius: 4px; font-size: 14px; cursor: pointer; transition: all 0.15s ease; } .html-toggle-mode:hover { background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')}; border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')}; } /* Content */ .html-content { position: relative; min-height: 120px; } /* Editor */ .html-editor { width: 100%; min-height: 120px; padding: 12px; background: transparent; border: none; outline: none; resize: none; font-family: 'Monaco', 'Consolas', 'Courier New', monospace; font-size: 13px; line-height: 1.6; color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')}; overflow: hidden; } .html-editor::placeholder { color: ${cssManager.bdTheme('#9ca3af', '#6b7280')}; } /* Preview */ .html-preview { padding: 12px; min-height: 96px; outline: none; font-size: 14px; line-height: 1.6; color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')}; } .preview-empty { color: ${cssManager.bdTheme('#9ca3af', '#6b7280')}; font-style: italic; } /* Sandboxed HTML preview styles */ .html-preview * { max-width: 100%; } .html-preview img { height: auto; } .html-preview a { color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')}; text-decoration: none; } .html-preview a:hover { text-decoration: underline; } .html-preview table { border-collapse: collapse; width: 100%; margin: 8px 0; } .html-preview th, .html-preview td { border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')}; padding: 8px; text-align: left; } .html-preview th { background: ${cssManager.bdTheme('#f9fafb', '#1f2937')}; font-weight: 600; } .html-preview pre { background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')}; padding: 12px; border-radius: 4px; overflow-x: auto; margin: 8px 0; } .html-preview code { background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')}; padding: 2px 4px; border-radius: 3px; font-family: 'Monaco', 'Consolas', 'Courier New', monospace; font-size: 0.9em; } .html-preview pre code { background: transparent; padding: 0; } `; } }