From 3ea7186d6c0b77140e2abef772f0e689cc220129 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 26 Jun 2025 11:57:04 +0000 Subject: [PATCH] update --- .../wysiwyg/blocks/text/code.block.old.ts | 608 ------------------ .../wysiwyg/blocks/text/code.block.ts | 101 ++- .../elements/wysiwyg/wysiwyg.modalmanager.ts | 172 ++--- 3 files changed, 198 insertions(+), 683 deletions(-) delete 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 deleted file mode 100644 index 5679638..0000000 --- a/ts_web/elements/wysiwyg/blocks/text/code.block.old.ts +++ /dev/null @@ -1,608 +0,0 @@ -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 2925d01..79ad4db 100644 --- a/ts_web/elements/wysiwyg/blocks/text/code.block.ts +++ b/ts_web/elements/wysiwyg/blocks/text/code.block.ts @@ -33,6 +33,13 @@ export class CodeBlockHandler extends BaseBlockHandler {
${language} +
${lineNumbersHtml}
@@ -51,9 +58,59 @@ export class CodeBlockHandler extends BaseBlockHandler { 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; if (!editor || !container) return; + // 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; @@ -325,7 +382,7 @@ export class CodeBlockHandler extends BaseBlockHandler { border-bottom: 1px solid ${cssManager.bdTheme('#d1d5da', '#30363d')}; padding: 8px 16px; display: flex; - justify-content: flex-end; + justify-content: space-between; align-items: center; } @@ -338,6 +395,48 @@ export class CodeBlockHandler extends BaseBlockHandler { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } + /* Copy Button */ + .copy-button { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + background: transparent; + border: 1px solid ${cssManager.bdTheme('#d1d5da', '#30363d')}; + border-radius: 6px; + color: ${cssManager.bdTheme('#57606a', '#8b949e')}; + font-size: 12px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + cursor: pointer; + transition: all 0.2s ease; + outline: none; + } + + .copy-button:hover { + background: ${cssManager.bdTheme('#f3f4f6', '#1c2128')}; + color: ${cssManager.bdTheme('#24292f', '#c9d1d9')}; + border-color: ${cssManager.bdTheme('#8b949e', '#484f58')}; + } + + .copy-button:active { + transform: scale(0.95); + } + + .copy-button.copied { + background: ${cssManager.bdTheme('#2ea043', '#238636')}; + border-color: ${cssManager.bdTheme('#2ea043', '#238636')}; + color: white; + } + + .copy-icon { + flex-shrink: 0; + } + + .copy-text { + min-width: 45px; + text-align: center; + } + /* Code Body */ .code-body { display: flex; diff --git a/ts_web/elements/wysiwyg/wysiwyg.modalmanager.ts b/ts_web/elements/wysiwyg/wysiwyg.modalmanager.ts index 3814869..86a707a 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.modalmanager.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.modalmanager.ts @@ -1,4 +1,4 @@ -import { html, type TemplateResult } from '@design.estate/dees-element'; +import { html, type TemplateResult, cssManager } from '@design.estate/dees-element'; import { DeesModal } from '../dees-modal.js'; import { type IBlock } from './wysiwyg.types.js'; import { WysiwygShortcuts } from './wysiwyg.shortcuts.js'; @@ -16,37 +16,56 @@ export class WysiwygModalManager { heading: 'Select Programming Language', content: html` -
- ${this.getLanguages().map(lang => html` -
${lang}
- `)} +
+
+ ${this.getLanguages().map(lang => html` +
+ ${lang} +
+ `)} +
`, menuOptions: [ @@ -56,13 +75,6 @@ export class WysiwygModalManager { modal.destroy(); resolve(null); } - }, - { - name: 'OK', - action: async (modal) => { - modal.destroy(); - resolve(selectedLanguage); - } } ] }); @@ -76,48 +88,61 @@ export class WysiwygModalManager { block: IBlock, onUpdate: (block: IBlock) => void ): Promise { + const content = html`
@@ -131,7 +156,7 @@ export class WysiwygModalManager { content, menuOptions: [ { - name: 'Close', + name: 'Done', action: async (modal) => { modal.destroy(); } @@ -147,57 +172,55 @@ export class WysiwygModalManager { block: IBlock, onUpdate: (block: IBlock) => void ): TemplateResult { - const currentLanguage = block.metadata?.language || 'plain text'; + const currentLanguage = block.metadata?.language || 'javascript'; return html`
Programming Language
${this.getLanguages().map(lang => html` -
${lang}
+ }}" + data-lang="${lang}" + >${lang}
`)}
@@ -228,6 +251,8 @@ export class WysiwygModalManager {