From 09e35d024531048948db7804ebbaabe1d7a4f1ca Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 26 Jun 2025 11:41:58 +0000 Subject: [PATCH] update codeblock --- .../wysiwyg/blocks/text/code.block.old.ts | 608 ++++++++++++++ .../wysiwyg/blocks/text/code.block.ts | 747 ++++++++++-------- ts_web/elements/wysiwyg/dees-wysiwyg-block.ts | 42 +- 3 files changed, 1038 insertions(+), 359 deletions(-) create mode 100644 ts_web/elements/wysiwyg/blocks/text/code.block.old.ts diff --git a/ts_web/elements/wysiwyg/blocks/text/code.block.old.ts b/ts_web/elements/wysiwyg/blocks/text/code.block.old.ts new file mode 100644 index 0000000..5679638 --- /dev/null +++ b/ts_web/elements/wysiwyg/blocks/text/code.block.old.ts @@ -0,0 +1,608 @@ +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) + }; + } +} \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/blocks/text/code.block.ts b/ts_web/elements/wysiwyg/blocks/text/code.block.ts index 9c94b8f..2925d01 100644 --- a/ts_web/elements/wysiwyg/blocks/text/code.block.ts +++ b/ts_web/elements/wysiwyg/blocks/text/code.block.ts @@ -1,81 +1,112 @@ 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'; +/** + * New CodeBlockHandler with improved architecture + * + * Key improvements: + * 1. Simpler DOM structure + * 2. Better line number handling + * 3. Non-intrusive syntax highlighting + * 4. Cleaner event handling + */ export class CodeBlockHandler extends BaseBlockHandler { type = 'code'; - // Track cursor position - private lastKnownCursorPosition: number = 0; + private highlightTimer: any = null; render(block: IBlock, isSelected: boolean): string { - const language = block.metadata?.language || 'plain text'; - const selectedClass = isSelected ? ' selected' : ''; + const language = block.metadata?.language || 'javascript'; + const content = block.content || ''; + const lineCount = content.split('\n').length; + + // Generate line numbers + let lineNumbersHtml = ''; + for (let i = 1; i <= lineCount; i++) { + lineNumbersHtml += `
${i}
`; + } return ` -
-
${language}
-
+
+
+ ${language} +
+
+
${lineNumbersHtml}
+
+
${this.escapeHtml(content)}
+
+
`; } 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; - } + const editor = element.querySelector('.code-editor') as HTMLElement; + const container = element.querySelector('.code-block-container') as HTMLElement; - // Set initial content if needed - use textContent for code blocks - if (block.content && !codeBlock.textContent) { - codeBlock.textContent = block.content; - } + if (!editor || !container) return; + + // Track if we're currently editing + let isEditing = false; + + // Focus handler + editor.addEventListener('focus', () => { + isEditing = true; + container.classList.add('editing'); + 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 - codeBlock.addEventListener('input', (e) => { + editor.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 + this.updateLineNumbers(element); + + // Clear any pending highlight + clearTimeout(this.highlightTimer); + + // Schedule highlighting (only if not actively editing) + if (!isEditing) { + this.highlightTimer = setTimeout(() => { + this.applyHighlighting(element, block); + }, 500); } }); // 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 + editor.addEventListener('keydown', (e) => { + // Handle Tab 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')); + this.updateLineNumbers(element); } return; } @@ -83,54 +114,8 @@ export class CodeBlockHandler extends BaseBlockHandler { 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', () => { - handlers.onCompositionStart(); - }); - - codeBlock.addEventListener('compositionend', () => { - handlers.onCompositionEnd(); - }); - - // 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) => { + // Paste handler - plain text only + editor.addEventListener('paste', (e) => { e.preventDefault(); const text = e.clipboardData?.getData('text/plain'); if (text) { @@ -144,257 +129,373 @@ export class CodeBlockHandler extends BaseBlockHandler { range.setEndAfter(textNode); selection.removeAllRanges(); selection.addRange(range); - - // Trigger input event 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 + if (block.content && !isEditing) { + 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 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 || 'javascript'; + + // 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') || 'javascript' + } + }; + 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 specific styles */ + /* Code Block Container */ .code-block-container { position: relative; - margin: 20px 0; + margin: 16px 0; + background: ${cssManager.bdTheme('#f6f8fa', '#0d1117')}; + border: 1px solid ${cssManager.bdTheme('#d1d5da', '#30363d')}; + border-radius: 8px; + overflow: hidden; + transition: border-color 0.2s ease, box-shadow 0.2s ease; } - .block.code { - font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; - font-size: 14px; - background: ${cssManager.bdTheme('#f8f8f8', '#0d0d0d')}; - border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')}; - padding: 16px 20px; - padding-top: 32px; - border-radius: 6px; - white-space: pre-wrap; - color: ${cssManager.bdTheme('#24292e', '#e1e4e8')}; - line-height: 1.5; - overflow-x: auto; - margin: 0; + .code-block-container.selected { + border-color: ${cssManager.bdTheme('#0969da', '#58a6ff')}; + box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(9, 105, 218, 0.15)', 'rgba(88, 166, 255, 0.15)')}; } - .code-language { - position: absolute; - top: 0; - right: 0; - background: ${cssManager.bdTheme('#e1e4e8', '#333333')}; - color: ${cssManager.bdTheme('#586069', '#8b949e')}; - padding: 4px 12px; + .code-block-container.editing { + border-color: ${cssManager.bdTheme('#0969da', '#58a6ff')}; + } + + /* Header */ + .code-header { + background: ${cssManager.bdTheme('#f6f8fa', '#161b22')}; + border-bottom: 1px solid ${cssManager.bdTheme('#d1d5da', '#30363d')}; + padding: 8px 16px; + display: flex; + justify-content: flex-end; + align-items: center; + } + + .language-label { font-size: 12px; - border-radius: 0 6px 0 6px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - text-transform: lowercase; - z-index: 1; + color: ${cssManager.bdTheme('#57606a', '#8b949e')}; + background: ${cssManager.bdTheme('#ffffff', '#0d1117')}; + padding: 2px 8px; + border-radius: 4px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + } + + /* Code Body */ + .code-body { + display: flex; + position: relative; + } + + /* Line Numbers */ + .line-numbers { + flex-shrink: 0; + padding: 16px 0; + background: ${cssManager.bdTheme('#f6f8fa', '#010409')}; + border-right: 1px solid ${cssManager.bdTheme('#d1d5da', '#30363d')}; + text-align: right; + user-select: none; + min-width: 50px; + } + + .line-number { + padding: 0 12px; + color: ${cssManager.bdTheme('#57606a', '#484f58')}; + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 14px; + line-height: 21px; + height: 21px; + } + + /* Code Content */ + .code-content { + flex: 1; + overflow-x: auto; + position: relative; + } + + .code-pre { + margin: 0; + padding: 0; + background: transparent; + } + + .code-editor { + display: block; + padding: 16px; + margin: 0; + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 14px; + line-height: 21px; + color: ${cssManager.bdTheme('#24292f', '#c9d1d9')}; + background: transparent; + border: none; + outline: none; + white-space: pre-wrap; + word-wrap: break-word; + min-height: 84px; + overflow: visible; + } + + /* Placeholder */ + .code-editor:empty::before { + content: "// Type or paste code here..."; + color: ${cssManager.bdTheme('#6a737d', '#484f58')}; + pointer-events: none; + } + + /* Syntax Highlighting Colors */ + .code-editor .hljs-keyword { + color: ${cssManager.bdTheme('#d73a49', '#ff7b72')}; + } + + .code-editor .hljs-string { + color: ${cssManager.bdTheme('#032f62', '#a5d6ff')}; + } + + .code-editor .hljs-number { + color: ${cssManager.bdTheme('#005cc5', '#79c0ff')}; + } + + .code-editor .hljs-function { + color: ${cssManager.bdTheme('#6f42c1', '#d2a8ff')}; + } + + .code-editor .hljs-comment { + color: ${cssManager.bdTheme('#6a737d', '#8b949e')}; + font-style: italic; + } + + .code-editor .hljs-variable, + .code-editor .hljs-attr { + color: ${cssManager.bdTheme('#e36209', '#ffa657')}; + } + + .code-editor .hljs-class, + .code-editor .hljs-title { + color: ${cssManager.bdTheme('#6f42c1', '#d2a8ff')}; + } + + .code-editor .hljs-params { + color: ${cssManager.bdTheme('#24292f', '#c9d1d9')}; + } + + .code-editor .hljs-built_in { + color: ${cssManager.bdTheme('#005cc5', '#79c0ff')}; + } + + .code-editor .hljs-literal { + color: ${cssManager.bdTheme('#005cc5', '#79c0ff')}; + } + + .code-editor .hljs-meta { + color: ${cssManager.bdTheme('#735c0f', '#f2cc60')}; + } + + .code-editor .hljs-punctuation { + color: ${cssManager.bdTheme('#24292f', '#c9d1d9')}; + } + + .code-editor .hljs-tag { + color: ${cssManager.bdTheme('#22863a', '#7ee83f')}; + } + + .code-editor .hljs-attribute { + color: ${cssManager.bdTheme('#6f42c1', '#d2a8ff')}; + } + + .code-editor .hljs-selector-tag { + color: ${cssManager.bdTheme('#22863a', '#7ee83f')}; + } + + .code-editor .hljs-selector-class { + color: ${cssManager.bdTheme('#6f42c1', '#d2a8ff')}; + } + + .code-editor .hljs-selector-id { + color: ${cssManager.bdTheme('#005cc5', '#79c0ff')}; + } + + /* Selection */ + .code-editor::selection, + .code-editor *::selection { + background: ${cssManager.bdTheme('rgba(9, 105, 218, 0.3)', 'rgba(88, 166, 255, 0.3)')}; + } + + /* Scrollbar styling */ + .code-content::-webkit-scrollbar { + height: 8px; + } + + .code-content::-webkit-scrollbar-track { + background: ${cssManager.bdTheme('#f6f8fa', '#010409')}; + } + + .code-content::-webkit-scrollbar-thumb { + background: ${cssManager.bdTheme('#d1d5da', '#30363d')}; + border-radius: 4px; + } + + .code-content::-webkit-scrollbar-thumb:hover { + background: ${cssManager.bdTheme('#c8c8c8', '#484f58')}; } `; } - - getPlaceholder(): string { - return ''; - } - - // Helper methods for code functionality - - 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; - - // 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) - }; - } } \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts b/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts index 9d20b15..adcda5d 100644 --- a/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts +++ b/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts @@ -48,12 +48,12 @@ export class DeesWysiwygBlock extends DeesElement { private lastKnownCursorPosition: number = 0; private lastSelectedText: string = ''; - private static handlerStylesInjected = false; + private handlerStylesInjected = false; private injectHandlerStyles(): void { - // Only inject once per component class - if (DeesWysiwygBlock.handlerStylesInjected) return; - DeesWysiwygBlock.handlerStylesInjected = true; + // Only inject once per instance + if (this.handlerStylesInjected) return; + this.handlerStylesInjected = true; // Get styles from all registered block handlers let styles = ''; @@ -131,20 +131,7 @@ export class DeesWysiwygBlock extends DeesElement { margin: 16px 0; } - .block.code { - font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; - font-size: 14px; - background: ${cssManager.bdTheme('#f8f8f8', '#0d0d0d')}; - border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')}; - padding: 16px 20px; - padding-top: 32px; - border-radius: 6px; - white-space: pre-wrap; - color: ${cssManager.bdTheme('#24292e', '#e1e4e8')}; - line-height: 1.5; - overflow-x: auto; - margin: 20px 0; - } + /* Code block styles moved to handler */ .block.list { padding: 0; @@ -200,24 +187,7 @@ export class DeesWysiwygBlock extends DeesElement { border-bottom-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')}; } - .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 6px 0 6px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - text-transform: lowercase; - z-index: 1; - } - - .code-block-container { - position: relative; - margin: 20px 0; - } + /* Code block container and language styles moved to handler */ /* Selection styles */ .block ::selection {