import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js'; import type { IBlock } from '../../wysiwyg.types.js'; import { cssManager } from '@design.estate/dees-element'; import { WysiwygBlocks } from '../../wysiwyg.blocks.js'; import { WysiwygSelection } from '../../wysiwyg.selection.js'; import hlight from 'highlight.js'; export class CodeBlockHandler extends BaseBlockHandler { type = 'code'; // Track cursor position private lastKnownCursorPosition: number = 0; // Debounce timer for highlighting private highlightingTimer: any = null; render(block: IBlock, isSelected: boolean): string { const language = block.metadata?.language || 'javascript'; const selectedClass = isSelected ? ' selected' : ''; return `
${language}
`; } setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void { const codeBlock = element.querySelector('.block.code') as HTMLDivElement; if (!codeBlock) { console.error('CodeBlockHandler.setup: No code block element found'); return; } // Set initial content if needed - use textContent for code blocks if (block.content && !codeBlock.textContent) { codeBlock.textContent = block.content; } // Apply initial highlighting this.applyHighlighting(element, block); // Variable to track if we're in composition mode let isComposing = false; // Input handler codeBlock.addEventListener('input', (e) => { handlers.onInput(e as InputEvent); // Track cursor position after input const pos = this.getCursorPosition(element); if (pos !== null) { this.lastKnownCursorPosition = pos; } // Update line numbers immediately const lineNumbersContainer = element.querySelector('.line-numbers') as HTMLDivElement; if (lineNumbersContainer) { this.updateLineNumbers(codeBlock.textContent || '', lineNumbersContainer); } // Debounce highlighting to avoid performance issues while typing if (!isComposing) { clearTimeout(this.highlightingTimer); this.highlightingTimer = setTimeout(() => { // Store cursor position before highlighting const currentPos = this.getCursorPosition(element); // Get plain text before highlighting const plainText = codeBlock.textContent || ''; // Apply highlighting const language = block.metadata?.language || 'javascript'; try { const result = hlight.highlight(plainText, { language: language, ignoreIllegals: true }); codeBlock.innerHTML = result.value; } catch (error) { // Keep plain text if highlighting fails } // Restore cursor position if (currentPos !== null && document.activeElement === codeBlock) { WysiwygSelection.setCursorPosition(codeBlock, currentPos); } }, 500); // Wait 500ms after user stops typing } }); // Keydown handler codeBlock.addEventListener('keydown', (e) => { // Track cursor position before keydown const pos = this.getCursorPosition(element); if (pos !== null) { this.lastKnownCursorPosition = pos; } // Special handling for Tab key in code blocks if (e.key === 'Tab') { e.preventDefault(); // Insert two spaces for tab const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); range.deleteContents(); const textNode = document.createTextNode(' '); range.insertNode(textNode); range.setStartAfter(textNode); range.setEndAfter(textNode); selection.removeAllRanges(); selection.addRange(range); // Trigger input event handlers.onInput(new InputEvent('input')); } return; } handlers.onKeyDown(e); }); // Focus handler codeBlock.addEventListener('focus', () => { handlers.onFocus(); }); // Blur handler codeBlock.addEventListener('blur', () => { handlers.onBlur(); }); // Composition handlers for IME support codeBlock.addEventListener('compositionstart', () => { isComposing = true; handlers.onCompositionStart(); }); codeBlock.addEventListener('compositionend', () => { isComposing = false; handlers.onCompositionEnd(); // Apply highlighting after composition ends setTimeout(() => { this.applyHighlighting(element, block); }, 100); }); // Mouse up handler codeBlock.addEventListener('mouseup', (e) => { const pos = this.getCursorPosition(element); if (pos !== null) { this.lastKnownCursorPosition = pos; } handlers.onMouseUp?.(e); }); // Click handler with delayed cursor tracking codeBlock.addEventListener('click', (_e: MouseEvent) => { setTimeout(() => { const pos = this.getCursorPosition(element); if (pos !== null) { this.lastKnownCursorPosition = pos; } }, 0); }); // Keyup handler for cursor tracking codeBlock.addEventListener('keyup', (_e) => { const pos = this.getCursorPosition(element); if (pos !== null) { this.lastKnownCursorPosition = pos; } }); // Paste handler - handle as plain text codeBlock.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); // Trigger input event handlers.onInput(new InputEvent('input')); } } }); } getStyles(): string { return ` /* Code block specific styles */ .code-block-container { position: relative; margin: 20px 0; background: ${cssManager.bdTheme('#f8f8f8', '#0d0d0d')}; border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')}; border-radius: 8px; overflow: hidden; transition: all 0.2s ease; } .code-block-container.selected { border-color: ${cssManager.bdTheme('#0066ff', '#4d9fff')}; box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 255, 0.1)', 'rgba(77, 159, 255, 0.1)')}; } .code-grid { display: grid; grid-template-columns: 50px 1fr; overflow: hidden; } .line-numbers { background: ${cssManager.bdTheme('#f3f3f3', '#0a0a0a')}; border-right: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')}; padding: 16px 12px 16px 0; text-align: right; user-select: none; color: ${cssManager.bdTheme('#999999', '#666666')}; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; font-size: 14px; line-height: 1.5; } .line-number { height: 21px; } .line-number:last-child { opacity: 0.5; } .block.code { font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; font-size: 14px; padding: 16px 20px; padding-top: 32px; white-space: pre-wrap; color: ${cssManager.bdTheme('#24292e', '#e1e4e8')}; line-height: 1.5; overflow-x: auto; margin: 0; min-height: 100px; background: transparent; border: none; outline: none; } .code-language { position: absolute; top: 0; right: 0; background: ${cssManager.bdTheme('#e1e4e8', '#333333')}; color: ${cssManager.bdTheme('#586069', '#8b949e')}; padding: 4px 12px; font-size: 12px; border-radius: 0 0 0 6px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; text-transform: lowercase; z-index: 1; } /* Highlight.js theme overrides */ .block.code .hljs-keyword { color: ${cssManager.bdTheme('#d73a49', '#ff65ec')}; font-weight: normal; } .block.code .hljs-string { color: ${cssManager.bdTheme('#032f62', '#ffa465')}; } .block.code .hljs-number { color: ${cssManager.bdTheme('#005cc5', '#65d5ff')}; } .block.code .hljs-function { color: ${cssManager.bdTheme('#6f42c1', '#6596ff')}; } .block.code .hljs-comment { color: ${cssManager.bdTheme('#6a737d', '#ffd765')}; font-style: italic; } .block.code .hljs-variable, .block.code .hljs-attr { color: ${cssManager.bdTheme('#e36209', '#65ff6a')}; } .block.code .hljs-class, .block.code .hljs-title { color: ${cssManager.bdTheme('#6f42c1', '#65d5ff')}; } .block.code .hljs-params { color: ${cssManager.bdTheme('#24292e', '#e1e4e8')}; } .block.code .hljs-built_in { color: ${cssManager.bdTheme('#005cc5', '#65ff6a')}; } .block.code .hljs-literal { color: ${cssManager.bdTheme('#005cc5', '#ff65ec')}; } .block.code .hljs-meta { color: ${cssManager.bdTheme('#735c0f', '#ffa465')}; } `; } getPlaceholder(): string { return ''; } // Helper methods for code functionality private applyHighlighting(element: HTMLElement, block: IBlock): void { const codeBlock = element.querySelector('.block.code') as HTMLDivElement; const lineNumbersContainer = element.querySelector('.line-numbers') as HTMLDivElement; if (!codeBlock || !lineNumbersContainer) return; // Store current cursor position const cursorPos = this.getCursorPosition(element); // Get the plain text content const plainText = codeBlock.textContent || ''; // Apply syntax highlighting const language = block.metadata?.language || 'javascript'; let highlightedHtml: string; try { const result = hlight.highlight(plainText, { language: language, ignoreIllegals: true }); highlightedHtml = result.value; } catch (error) { // Fallback to plain text if highlighting fails highlightedHtml = this.escapeHtml(plainText); } // Update the code block with highlighted content codeBlock.innerHTML = highlightedHtml; // Update line numbers this.updateLineNumbers(plainText, lineNumbersContainer); // Restore cursor position if we had one if (cursorPos !== null && document.activeElement === codeBlock) { WysiwygSelection.setCursorPosition(codeBlock, cursorPos); } } private updateLineNumbers(content: string, container: HTMLDivElement): void { const lines = content.split('\n'); const lineCount = lines.length; // Generate line numbers HTML let lineNumbersHtml = ''; for (let i = 1; i <= lineCount; i++) { lineNumbersHtml += `
${i}
`; } container.innerHTML = lineNumbersHtml; } private escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } getCursorPosition(element: HTMLElement, context?: any): number | null { // Get the actual code element const codeBlock = element.querySelector('.block.code') as HTMLDivElement; if (!codeBlock) { return null; } // Get shadow roots from context const wysiwygBlock = context?.component; const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg'); const parentShadowRoot = parentComponent?.shadowRoot; const blockShadowRoot = context?.shadowRoot; // Get selection info with both shadow roots for proper traversal const shadowRoots: ShadowRoot[] = []; if (parentShadowRoot) shadowRoots.push(parentShadowRoot); if (blockShadowRoot) shadowRoots.push(blockShadowRoot); const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots); if (!selectionInfo) { return null; } if (!WysiwygSelection.containsAcrossShadowDOM(codeBlock, selectionInfo.startContainer)) { return null; } // Create a range from start of element to cursor position const preCaretRange = document.createRange(); preCaretRange.selectNodeContents(codeBlock); preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset); // Get the text content length up to cursor const position = preCaretRange.toString().length; return position; } getContent(element: HTMLElement, _context?: any): string { const codeBlock = element.querySelector('.block.code') as HTMLDivElement; if (!codeBlock) return ''; // For code blocks, get textContent to avoid HTML formatting const content = codeBlock.textContent || ''; return content; } setContent(element: HTMLElement, content: string, context?: any): void { const codeBlock = element.querySelector('.block.code') as HTMLDivElement; if (!codeBlock) return; // Store if we have focus const hadFocus = document.activeElement === codeBlock || element.shadowRoot?.activeElement === codeBlock; // Use textContent for code blocks codeBlock.textContent = content; // Apply highlighting const block: IBlock = { id: codeBlock.dataset.blockId || '', type: 'code', content: content, metadata: context?.block?.metadata || {} }; this.applyHighlighting(element, block); // Restore focus if we had it if (hadFocus) { codeBlock.focus(); } } setCursorToStart(element: HTMLElement, _context?: any): void { const codeBlock = element.querySelector('.block.code') as HTMLDivElement; if (codeBlock) { WysiwygBlocks.setCursorToStart(codeBlock); } } setCursorToEnd(element: HTMLElement, _context?: any): void { const codeBlock = element.querySelector('.block.code') as HTMLDivElement; if (codeBlock) { WysiwygBlocks.setCursorToEnd(codeBlock); } } focus(element: HTMLElement, _context?: any): void { const codeBlock = element.querySelector('.block.code') as HTMLDivElement; if (!codeBlock) return; // Ensure the element is focusable if (!codeBlock.hasAttribute('contenteditable')) { codeBlock.setAttribute('contenteditable', 'true'); } codeBlock.focus(); // If focus failed, try again after a microtask if (document.activeElement !== codeBlock && element.shadowRoot?.activeElement !== codeBlock) { Promise.resolve().then(() => { codeBlock.focus(); }); } } focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void { const codeBlock = element.querySelector('.block.code') as HTMLDivElement; if (!codeBlock) return; // Ensure element is focusable first if (!codeBlock.hasAttribute('contenteditable')) { codeBlock.setAttribute('contenteditable', 'true'); } // Focus the element codeBlock.focus(); // Set cursor position after focus is established const setCursor = () => { if (position === 'start') { this.setCursorToStart(element, context); } else if (position === 'end') { this.setCursorToEnd(element, context); } else if (typeof position === 'number') { // Use the selection utility to set cursor position WysiwygSelection.setCursorPosition(codeBlock, position); } }; // Ensure cursor is set after focus if (document.activeElement === codeBlock || element.shadowRoot?.activeElement === codeBlock) { setCursor(); } else { // Wait for focus to be established Promise.resolve().then(() => { if (document.activeElement === codeBlock || element.shadowRoot?.activeElement === codeBlock) { setCursor(); } }); } } getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null { const codeBlock = element.querySelector('.block.code') as HTMLDivElement; if (!codeBlock) { return null; } // Get shadow roots from context const wysiwygBlock = context?.component; const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg'); const parentShadowRoot = parentComponent?.shadowRoot; const blockShadowRoot = context?.shadowRoot; // Get selection info with both shadow roots for proper traversal const shadowRoots: ShadowRoot[] = []; if (parentShadowRoot) shadowRoots.push(parentShadowRoot); if (blockShadowRoot) shadowRoots.push(blockShadowRoot); const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots); if (!selectionInfo) { // Try using last known cursor position if (this.lastKnownCursorPosition !== null) { const fullText = codeBlock.textContent || ''; const pos = Math.min(this.lastKnownCursorPosition, fullText.length); return { before: fullText.substring(0, pos), after: fullText.substring(pos) }; } return null; } // Make sure the selection is within this block if (!WysiwygSelection.containsAcrossShadowDOM(codeBlock, selectionInfo.startContainer)) { // Try using last known cursor position if (this.lastKnownCursorPosition !== null) { const fullText = codeBlock.textContent || ''; const pos = Math.min(this.lastKnownCursorPosition, fullText.length); return { before: fullText.substring(0, pos), after: fullText.substring(pos) }; } return null; } // Get cursor position const cursorPos = this.getCursorPosition(element, context); if (cursorPos === null || cursorPos === 0) { // If cursor is at start or can't determine position, move all content return { before: '', after: codeBlock.textContent || '' }; } // For code blocks, split based on text content only const fullText = codeBlock.textContent || ''; return { before: fullText.substring(0, cursorPos), after: fullText.substring(cursorPos) }; } }