import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js'; import type { IBlock } from '../../wysiwyg.types.js'; import { cssManager } from '@design.estate/dees-element'; import { WysiwygSelection } from '../../wysiwyg.selection.js'; import hlight from 'highlight.js'; import { cssGeistFontFamily, cssMonoFontFamily } from '../../../00fonts.js'; import { PROGRAMMING_LANGUAGES } from '../../wysiwyg.constants.js'; /** * CodeBlockHandler with improved architecture * * Key features: * 1. Simple DOM structure * 2. Line number handling * 3. Syntax highlighting only when not focused (grey text while editing) * 4. Clean event handling * 5. Copy button functionality */ export class CodeBlockHandler extends BaseBlockHandler { type = 'code'; private highlightTimer: any = null; render(block: IBlock, isSelected: boolean): string { const language = block.metadata?.language || 'typescript'; const content = block.content || ''; const lineCount = content.split('\n').length; // Generate line numbers let lineNumbersHtml = ''; for (let i = 1; i <= lineCount; i++) { lineNumbersHtml += `
${i}
`; } // Generate language options const languageOptions = PROGRAMMING_LANGUAGES.map(lang => { const value = lang.toLowerCase(); return ``; }).join(''); return `
${lineNumbersHtml}
${this.escapeHtml(content)}
`; } setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void { const editor = element.querySelector('.code-editor') as HTMLElement; const container = element.querySelector('.code-block-container') as HTMLElement; const copyButton = element.querySelector('.copy-button') as HTMLButtonElement; const languageSelector = element.querySelector('.language-selector') as HTMLSelectElement; if (!editor || !container) return; // Setup language selector if (languageSelector) { languageSelector.addEventListener('change', (e) => { const newLanguage = (e.target as HTMLSelectElement).value; block.metadata = { ...block.metadata, language: newLanguage }; container.setAttribute('data-language', newLanguage); // Update the syntax highlighting if content exists and not focused if (block.content && document.activeElement !== editor) { this.applyHighlighting(element, block); } // Notify about the change if (handlers.onInput) { handlers.onInput(new InputEvent('input')); } }); } // Setup copy button if (copyButton) { copyButton.addEventListener('click', async () => { const content = editor.textContent || ''; try { await navigator.clipboard.writeText(content); // Show feedback const copyText = copyButton.querySelector('.copy-text') as HTMLElement; const originalText = copyText.textContent; copyText.textContent = 'Copied!'; copyButton.classList.add('copied'); // Reset after 2 seconds setTimeout(() => { copyText.textContent = originalText; copyButton.classList.remove('copied'); }, 2000); } catch (err) { console.error('Failed to copy:', err); // Fallback for older browsers const textArea = document.createElement('textarea'); textArea.value = content; textArea.style.position = 'fixed'; textArea.style.opacity = '0'; document.body.appendChild(textArea); textArea.select(); try { // @ts-ignore - execCommand is deprecated but needed for fallback document.execCommand('copy'); // Show feedback const copyText = copyButton.querySelector('.copy-text') as HTMLElement; const originalText = copyText.textContent; copyText.textContent = 'Copied!'; copyButton.classList.add('copied'); setTimeout(() => { copyText.textContent = originalText; copyButton.classList.remove('copied'); }, 2000); } catch (err) { console.error('Fallback copy failed:', err); } document.body.removeChild(textArea); } }); } // Track if we're currently editing let isEditing = false; // Focus handler editor.addEventListener('focus', () => { isEditing = true; container.classList.add('editing'); // Remove all syntax highlighting when focused const content = editor.textContent || ''; editor.textContent = content; // This removes all HTML formatting // Restore cursor position after removing highlighting requestAnimationFrame(() => { const range = document.createRange(); const selection = window.getSelection(); if (editor.firstChild) { range.setStart(editor.firstChild, 0); range.collapse(true); selection?.removeAllRanges(); selection?.addRange(range); } }); handlers.onFocus(); }); // Blur handler editor.addEventListener('blur', () => { isEditing = false; container.classList.remove('editing'); // Apply final highlighting on blur this.applyHighlighting(element, block); handlers.onBlur(); }); // Input handler editor.addEventListener('input', (e) => { handlers.onInput(e as InputEvent); // Update line numbers this.updateLineNumbers(element); // Clear any pending highlight timer (no highlighting while editing) clearTimeout(this.highlightTimer); }); // Keydown handler editor.addEventListener('keydown', (e) => { // Handle Tab key for code blocks if (e.key === 'Tab') { e.preventDefault(); const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); const textNode = document.createTextNode(' '); range.insertNode(textNode); range.setStartAfter(textNode); range.setEndAfter(textNode); selection.removeAllRanges(); selection.addRange(range); handlers.onInput(new InputEvent('input')); this.updateLineNumbers(element); } return; } // Check cursor position for navigation keys if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) { const cursorPos = this.getCursorPosition(element); const textLength = editor.textContent?.length || 0; // For ArrowLeft at position 0 or ArrowRight at end, let parent handle navigation if ((e.key === 'ArrowLeft' && cursorPos === 0) || (e.key === 'ArrowRight' && cursorPos === textLength)) { // Pass to parent handler for inter-block navigation handlers.onKeyDown(e); return; } // For ArrowUp/Down, check if we're at first/last line if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { const lines = (editor.textContent || '').split('\n'); const currentLine = this.getCurrentLineIndex(editor); if ((e.key === 'ArrowUp' && currentLine === 0) || (e.key === 'ArrowDown' && currentLine === lines.length - 1)) { // Let parent handle navigation to prev/next block handlers.onKeyDown(e); return; } } } // Pass other keys to parent handler handlers.onKeyDown(e); }); // Paste handler - plain text only editor.addEventListener('paste', (e) => { e.preventDefault(); const text = e.clipboardData?.getData('text/plain'); if (text) { const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); range.deleteContents(); const textNode = document.createTextNode(text); range.insertNode(textNode); range.setStartAfter(textNode); range.setEndAfter(textNode); selection.removeAllRanges(); selection.addRange(range); handlers.onInput(new InputEvent('input')); this.updateLineNumbers(element); } } }); // Composition handlers editor.addEventListener('compositionstart', () => handlers.onCompositionStart()); editor.addEventListener('compositionend', () => handlers.onCompositionEnd()); // Initial syntax highlighting if content exists and not focused if (block.content && document.activeElement !== editor) { requestAnimationFrame(() => { this.applyHighlighting(element, block); }); } } private updateLineNumbers(element: HTMLElement): void { const editor = element.querySelector('.code-editor') as HTMLElement; const lineNumbersContainer = element.querySelector('.line-numbers') as HTMLElement; if (!editor || !lineNumbersContainer) return; const content = editor.textContent || ''; const lines = content.split('\n'); const lineCount = lines.length || 1; let lineNumbersHtml = ''; for (let i = 1; i <= lineCount; i++) { lineNumbersHtml += `
${i}
`; } lineNumbersContainer.innerHTML = lineNumbersHtml; } private getCurrentLineIndex(editor: HTMLElement): number { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return 0; const range = selection.getRangeAt(0); const preCaretRange = range.cloneRange(); preCaretRange.selectNodeContents(editor); preCaretRange.setEnd(range.startContainer, range.startOffset); const textBeforeCursor = preCaretRange.toString(); const linesBeforeCursor = textBeforeCursor.split('\n'); return linesBeforeCursor.length - 1; // 0-indexed } private applyHighlighting(element: HTMLElement, block: IBlock): void { const editor = element.querySelector('.code-editor') as HTMLElement; if (!editor) return; // Store cursor position const cursorPos = this.getCursorPosition(element); // Get plain text content const content = editor.textContent || ''; const language = block.metadata?.language || 'typescript'; // Apply highlighting try { const result = hlight.highlight(content, { language: language, ignoreIllegals: true }); // Only update if we have valid highlighted content if (result.value) { editor.innerHTML = result.value; // Restore cursor position if editor is focused if (document.activeElement === editor && cursorPos !== null) { requestAnimationFrame(() => { WysiwygSelection.setCursorPosition(editor, cursorPos); }); } } } catch (error) { // If highlighting fails, keep plain text console.warn('Syntax highlighting failed:', error); } } private escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } getContent(element: HTMLElement): string { const editor = element.querySelector('.code-editor') as HTMLElement; return editor?.textContent || ''; } setContent(element: HTMLElement, content: string): void { const editor = element.querySelector('.code-editor') as HTMLElement; if (!editor) return; editor.textContent = content; this.updateLineNumbers(element); // Apply highlighting if not focused if (document.activeElement !== editor) { const block: IBlock = { id: editor.dataset.blockId || '', type: 'code', content: content, metadata: { language: element.querySelector('.code-block-container')?.getAttribute('data-language') || 'typescript' } }; this.applyHighlighting(element, block); } } getCursorPosition(element: HTMLElement): number | null { const editor = element.querySelector('.code-editor') as HTMLElement; if (!editor) return null; const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return null; const range = selection.getRangeAt(0); if (!editor.contains(range.startContainer)) return null; const preCaretRange = document.createRange(); preCaretRange.selectNodeContents(editor); preCaretRange.setEnd(range.startContainer, range.startOffset); return preCaretRange.toString().length; } setCursorToStart(element: HTMLElement): void { const editor = element.querySelector('.code-editor') as HTMLElement; if (editor) { WysiwygSelection.setCursorPosition(editor, 0); } } setCursorToEnd(element: HTMLElement): void { const editor = element.querySelector('.code-editor') as HTMLElement; if (editor) { const length = editor.textContent?.length || 0; WysiwygSelection.setCursorPosition(editor, length); } } focus(element: HTMLElement): void { const editor = element.querySelector('.code-editor') as HTMLElement; editor?.focus(); } focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void { const editor = element.querySelector('.code-editor') as HTMLElement; if (!editor) return; editor.focus(); requestAnimationFrame(() => { if (position === 'start') { this.setCursorToStart(element); } else if (position === 'end') { this.setCursorToEnd(element); } else if (typeof position === 'number') { WysiwygSelection.setCursorPosition(editor, position); } }); } getSplitContent(element: HTMLElement): { before: string; after: string } | null { const position = this.getCursorPosition(element); if (position === null) return null; const content = this.getContent(element); return { before: content.substring(0, position), after: content.substring(position) }; } getStyles(): string { return ` /* Code Block Container - Minimalist shadcn style */ .code-block-container { position: relative; margin: 12px 0; background: transparent; border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')}; border-radius: 6px; overflow: hidden; transition: all 0.15s ease; } .code-block-container.selected { border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')}; } .code-block-container.editing { border-color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')}; } /* Header - Simplified */ .code-header { background: transparent; border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')}; padding: 8px 12px; display: flex; justify-content: space-between; align-items: center; } .language-selector { font-size: 12px; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; font-family: ${cssGeistFontFamily}; background: transparent; border: 1px solid transparent; border-radius: 4px; padding: 4px 8px; cursor: pointer; transition: all 0.15s ease; outline: none; } .language-selector:hover { background: ${cssManager.bdTheme('#f9fafb', '#1f2937')}; border-color: ${cssManager.bdTheme('#e5e7eb', '#374151')}; color: ${cssManager.bdTheme('#374151', '#e5e7eb')}; } .language-selector:focus { border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')}; } /* Copy Button - Minimal */ .copy-button { display: flex; align-items: center; gap: 4px; padding: 4px 8px; background: transparent; border: 1px solid transparent; border-radius: 4px; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; font-size: 12px; font-family: ${cssGeistFontFamily}; cursor: pointer; transition: all 0.15s ease; outline: none; } .copy-button:hover { background: ${cssManager.bdTheme('#f9fafb', '#1f2937')}; border-color: ${cssManager.bdTheme('#e5e7eb', '#374151')}; color: ${cssManager.bdTheme('#374151', '#e5e7eb')}; } .copy-button:active { transform: scale(0.98); } .copy-button.copied { color: ${cssManager.bdTheme('#059669', '#10b981')}; } .copy-icon { flex-shrink: 0; opacity: 0.7; } .copy-button:hover .copy-icon { opacity: 1; } .copy-text { min-width: 40px; text-align: center; } /* Code Body */ .code-body { display: flex; position: relative; background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')}; } /* Line Numbers - Subtle */ .line-numbers { flex-shrink: 0; padding: 12px 0; background: transparent; text-align: right; user-select: none; min-width: 40px; border-right: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')}; } .line-number { padding: 0 12px 0 8px; color: ${cssManager.bdTheme('#9ca3af', '#4b5563')}; font-family: ${cssMonoFontFamily}; font-size: 13px; line-height: 20px; height: 20px; } /* Code Content */ .code-content { flex: 1; overflow-x: auto; position: relative; } .code-pre { margin: 0; padding: 0; background: transparent; } .code-editor { display: block; padding: 12px 16px; margin: 0; font-family: ${cssMonoFontFamily}; font-size: 13px; line-height: 20px; color: ${cssManager.bdTheme('#111827', '#f9fafb')}; background: transparent; border: none; outline: none; white-space: pre-wrap; word-wrap: break-word; min-height: 60px; overflow: visible; } /* Placeholder */ .code-editor:empty::before { content: "// Type or paste code here..."; color: ${cssManager.bdTheme('#9ca3af', '#4b5563')}; pointer-events: none; } /* When editing (focused), show grey text without highlighting */ .code-block-container.editing .code-editor { color: ${cssManager.bdTheme('#6b7280', '#9ca3af')} !important; } .code-block-container.editing .code-editor * { color: inherit !important; } /* Syntax Highlighting - Muted colors */ .code-editor .hljs-keyword { color: ${cssManager.bdTheme('#dc2626', '#f87171')}; font-weight: 500; } .code-editor .hljs-string { color: ${cssManager.bdTheme('#059669', '#10b981')}; } .code-editor .hljs-number { color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')}; } .code-editor .hljs-function { color: ${cssManager.bdTheme('#2563eb', '#60a5fa')}; } .code-editor .hljs-comment { color: ${cssManager.bdTheme('#6b7280', '#6b7280')}; font-style: italic; } .code-editor .hljs-variable, .code-editor .hljs-attr { color: ${cssManager.bdTheme('#ea580c', '#fb923c')}; } .code-editor .hljs-class, .code-editor .hljs-title { color: ${cssManager.bdTheme('#2563eb', '#60a5fa')}; font-weight: 500; } .code-editor .hljs-params { color: ${cssManager.bdTheme('#374151', '#e5e7eb')}; } .code-editor .hljs-built_in { color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')}; } .code-editor .hljs-literal { color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')}; } .code-editor .hljs-meta { color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; } .code-editor .hljs-punctuation { color: ${cssManager.bdTheme('#374151', '#d1d5db')}; } .code-editor .hljs-tag { color: ${cssManager.bdTheme('#dc2626', '#f87171')}; } .code-editor .hljs-attribute { color: ${cssManager.bdTheme('#2563eb', '#60a5fa')}; } .code-editor .hljs-selector-tag { color: ${cssManager.bdTheme('#dc2626', '#f87171')}; } .code-editor .hljs-selector-class { color: ${cssManager.bdTheme('#2563eb', '#60a5fa')}; } .code-editor .hljs-selector-id { color: ${cssManager.bdTheme('#7c3aed', '#a78bfa')}; } /* Selection */ .code-editor::selection, .code-editor *::selection { background: ${cssManager.bdTheme('rgba(99, 102, 241, 0.2)', 'rgba(99, 102, 241, 0.3)')}; } /* Scrollbar styling - Minimal */ .code-content::-webkit-scrollbar { height: 6px; } .code-content::-webkit-scrollbar-track { background: transparent; } .code-content::-webkit-scrollbar-thumb { background: ${cssManager.bdTheme('#d1d5db', '#4b5563')}; border-radius: 3px; } .code-content::-webkit-scrollbar-thumb:hover { background: ${cssManager.bdTheme('#9ca3af', '#6b7280')}; } `; } }