519 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			519 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | 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 `
 | ||
|  |       <div class="html-block-container${isSelected ? ' selected' : ''}"  | ||
|  |            data-block-id="${block.id}" | ||
|  |            data-edit-mode="${isEditMode}"> | ||
|  |         <div class="html-header"> | ||
|  |           <div class="html-icon"></></div> | ||
|  |           <div class="html-title">HTML</div> | ||
|  |           <button class="html-toggle-mode" title="${isEditMode ? 'Preview' : 'Edit'}"> | ||
|  |             ${isEditMode ? '👁️' : '✏️'} | ||
|  |           </button> | ||
|  |         </div> | ||
|  |         <div class="html-content"> | ||
|  |           ${isEditMode ? this.renderEditor(content) : this.renderPreview(content)} | ||
|  |         </div> | ||
|  |       </div> | ||
|  |     `;
 | ||
|  |   } | ||
|  |    | ||
|  |   private renderEditor(content: string): string { | ||
|  |     return `
 | ||
|  |       <textarea class="html-editor"  | ||
|  |                 placeholder="Enter HTML content..." | ||
|  |                 spellcheck="false">${this.escapeHtml(content)}</textarea> | ||
|  |     `;
 | ||
|  |   } | ||
|  |    | ||
|  |   private renderPreview(content: string): string { | ||
|  |     return `
 | ||
|  |       <div class="html-preview"> | ||
|  |         ${content || '<div class="preview-empty">No content to preview</div>'} | ||
|  |       </div> | ||
|  |     `;
 | ||
|  |   } | ||
|  |    | ||
|  |   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 = `</${tagName}>`; | ||
|  |          | ||
|  |         // 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('</'); | ||
|  |       const tagName = match[1].toLowerCase(); | ||
|  |        | ||
|  |       if (isClosing) { | ||
|  |         if (openTags.length === 0 || openTags[openTags.length - 1] !== tagName) { | ||
|  |           console.warn(`Mismatched closing tag: ${tagName}`); | ||
|  |           return false; | ||
|  |         } | ||
|  |         openTags.pop(); | ||
|  |       } else if (!match[0].endsWith('/>')) { | ||
|  |         // 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; | ||
|  |       } | ||
|  |     `;
 | ||
|  |   } | ||
|  | } |