From 4b2178cedd927bb572f4a153195421363e3e9b1d Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 24 Jun 2025 13:41:12 +0000 Subject: [PATCH] fix(wysiwyg):Improve Wysiwyg editor --- ts_web/elements/wysiwyg/dees-input-wysiwyg.ts | 210 ++-------- ts_web/elements/wysiwyg/dees-wysiwyg-block.ts | 377 +++++++++++++----- ts_web/elements/wysiwyg/index.ts | 2 + ts_web/elements/wysiwyg/instructions.md | 5 +- .../wysiwyg/wysiwyg.blockoperations.ts | 5 +- ts_web/elements/wysiwyg/wysiwyg.constants.ts | 27 ++ .../wysiwyg/wysiwyg.dragdrophandler.ts | 5 +- ts_web/elements/wysiwyg/wysiwyg.formatting.ts | 72 +--- .../elements/wysiwyg/wysiwyg.inputhandler.ts | 15 +- ts_web/elements/wysiwyg/wysiwyg.interfaces.ts | 12 +- .../wysiwyg/wysiwyg.keyboardhandler.ts | 36 +- .../elements/wysiwyg/wysiwyg.modalmanager.ts | 7 +- ts_web/elements/wysiwyg/wysiwyg.selection.ts | 157 ++++++++ 13 files changed, 581 insertions(+), 349 deletions(-) create mode 100644 ts_web/elements/wysiwyg/wysiwyg.constants.ts create mode 100644 ts_web/elements/wysiwyg/wysiwyg.selection.ts diff --git a/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts index 72bf00c..7664f87 100644 --- a/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts +++ b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts @@ -6,7 +6,7 @@ import { customElement, type TemplateResult, property, - html, + static as html, cssManager, state, } from '@design.estate/dees-element'; @@ -44,7 +44,7 @@ export class DeesInputWysiwyg extends DeesInputBase { public outputFormat: OutputFormat = 'html'; @state() - private blocks: IBlock[] = [ + public blocks: IBlock[] = [ { id: WysiwygShortcuts.generateBlockId(), type: 'paragraph', @@ -53,38 +53,42 @@ export class DeesInputWysiwyg extends DeesInputBase { ]; // Not using @state to avoid re-renders when selection changes - private selectedBlockId: string | null = null; + public selectedBlockId: string | null = null; // Slash menu is now globally rendered - private slashMenu = DeesSlashMenu.getInstance(); + public slashMenu = DeesSlashMenu.getInstance(); @state() - private draggedBlockId: string | null = null; + public draggedBlockId: string | null = null; @state() - private dragOverBlockId: string | null = null; + public dragOverBlockId: string | null = null; @state() - private dragOverPosition: 'before' | 'after' | null = null; + public dragOverPosition: 'before' | 'after' | null = null; // Formatting menu is now globally rendered - private formattingMenu = DeesFormattingMenu.getInstance(); + public formattingMenu = DeesFormattingMenu.getInstance(); @state() private selectedText: string = ''; - private editorContentRef: HTMLDivElement; - private isComposing: boolean = false; - private selectionChangeHandler = () => this.handleSelectionChange(); + public editorContentRef: HTMLDivElement; + public isComposing: boolean = false; + private selectionChangeTimeout: any; + private selectionChangeHandler = () => { + // Throttle selection change events + if (this.selectionChangeTimeout) { + clearTimeout(this.selectionChangeTimeout); + } + this.selectionChangeTimeout = setTimeout(() => this.handleSelectionChange(), 50); + }; // Handler instances - private blockOperations: WysiwygBlockOperations; + public blockOperations: WysiwygBlockOperations; private inputHandler: WysiwygInputHandler; private keyboardHandler: WysiwygKeyboardHandler; private dragDropHandler: WysiwygDragDropHandler; - - // Content cache to avoid triggering re-renders during typing - private contentCache: Map = new Map(); public static styles = [ ...DeesInputBase.baseStyles, @@ -116,6 +120,11 @@ export class DeesInputWysiwyg extends DeesInputBase { clearTimeout(this.blurTimeout); this.blurTimeout = null; } + // Clean up selection change timeout + if (this.selectionChangeTimeout) { + clearTimeout(this.selectionChangeTimeout); + this.selectionChangeTimeout = null; + } } async firstUpdated() { @@ -141,7 +150,7 @@ export class DeesInputWysiwyg extends DeesInputBase { /** * Renders all blocks programmatically without triggering re-renders */ - private renderBlocksProgrammatically() { + public renderBlocksProgrammatically() { if (!this.editorContentRef) return; // Clear existing blocks @@ -157,7 +166,7 @@ export class DeesInputWysiwyg extends DeesInputBase { /** * Creates a block element programmatically */ - private createBlockElement(block: IBlock): HTMLElement { + public createBlockElement(block: IBlock): HTMLElement { const wrapper = document.createElement('div'); wrapper.className = 'block-wrapper'; wrapper.setAttribute('data-block-id', block.id); @@ -200,7 +209,7 @@ export class DeesInputWysiwyg extends DeesInputBase { settings.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); - WysiwygModalManager.showBlockSettingsModal(block, (updatedBlock) => { + WysiwygModalManager.showBlockSettingsModal(block, () => { this.updateValue(); // Re-render only the updated block this.updateBlockElement(block.id); @@ -220,7 +229,7 @@ export class DeesInputWysiwyg extends DeesInputBase { /** * Updates a specific block element */ - private updateBlockElement(blockId: string) { + public updateBlockElement(blockId: string) { const block = this.blocks.find(b => b.id === blockId); if (!block) return; @@ -257,7 +266,7 @@ export class DeesInputWysiwyg extends DeesInputBase { - private handleSlashMenuKeyboard(e: KeyboardEvent) { + public handleSlashMenuKeyboard(e: KeyboardEvent) { switch(e.key) { case 'ArrowDown': e.preventDefault(); @@ -306,39 +315,6 @@ export class DeesInputWysiwyg extends DeesInputBase { this.slashMenu.hide(); } - private detectBlockTypeIntent(content: string): { type: IBlock['type'], listType?: 'bullet' | 'ordered' } | null { - // Check heading patterns - const headingResult = WysiwygShortcuts.checkHeadingShortcut(content); - if (headingResult) { - return headingResult; - } - - // Check list patterns - const listResult = WysiwygShortcuts.checkListShortcut(content); - if (listResult) { - return listResult; - } - - // Check quote pattern - if (WysiwygShortcuts.checkQuoteShortcut(content)) { - return { type: 'quote' }; - } - - // Check code pattern - if (WysiwygShortcuts.checkCodeShortcut(content)) { - return { type: 'code' }; - } - - // Check divider pattern - if (WysiwygShortcuts.checkDividerShortcut(content)) { - return { type: 'divider' }; - } - - // Don't automatically revert to paragraph - blocks should keep their type - // unless explicitly changed by the user - return null; - } - private handleBlockFocus(block: IBlock) { // Clear any pending blur timeout when focusing if (this.blurTimeout) { @@ -533,7 +509,7 @@ export class DeesInputWysiwyg extends DeesInputBase { } } - private updateValue() { + public updateValue() { if (this.outputFormat === 'html') { this.value = WysiwygConverters.getHtmlOutput(this.blocks); } else { @@ -625,26 +601,6 @@ export class DeesInputWysiwyg extends DeesInputBase { this.importBlocks(state.blocks); } - // Drag and Drop Handlers - private handleDragStart(e: DragEvent, block: IBlock): void { - if (!e.dataTransfer) return; - - this.draggedBlockId = block.id; - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', block.id); - - // Add dragging class to the wrapper - setTimeout(() => { - const wrapper = this.editorContentRef.querySelector(`[data-block-id="${block.id}"]`); - if (wrapper) { - wrapper.classList.add('dragging'); - } - - // Add dragging class to editor content - this.editorContentRef.classList.add('dragging'); - }, 10); - } - private handleDragEnd(): void { // Remove all drag-related classes if (this.draggedBlockId) { @@ -668,44 +624,7 @@ export class DeesInputWysiwyg extends DeesInputBase { this.dragOverPosition = null; } - private handleDragOver(e: DragEvent, block: IBlock): void { - e.preventDefault(); - if (!e.dataTransfer || !this.draggedBlockId || this.draggedBlockId === block.id) return; - - e.dataTransfer.dropEffect = 'move'; - - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const midpoint = rect.top + rect.height / 2; - - // Remove previous drag-over classes - if (this.dragOverBlockId) { - const prevWrapper = this.editorContentRef.querySelector(`[data-block-id="${this.dragOverBlockId}"]`); - if (prevWrapper) { - prevWrapper.classList.remove('drag-over-before', 'drag-over-after'); - } - } - - this.dragOverBlockId = block.id; - this.dragOverPosition = e.clientY < midpoint ? 'before' : 'after'; - - // Add new drag-over class - const wrapper = e.currentTarget as HTMLElement; - wrapper.classList.add(`drag-over-${this.dragOverPosition}`); - } - - private handleDragLeave(block: IBlock): void { - if (this.dragOverBlockId === block.id) { - const wrapper = this.editorContentRef.querySelector(`[data-block-id="${block.id}"]`); - if (wrapper) { - wrapper.classList.remove('drag-over-before', 'drag-over-after'); - } - - this.dragOverBlockId = null; - this.dragOverPosition = null; - } - } - - private handleDrop(e: DragEvent, targetBlock: IBlock): void { + public handleDrop(e: DragEvent, targetBlock: IBlock): void { e.preventDefault(); if (!this.draggedBlockId || this.draggedBlockId === targetBlock.id) return; @@ -746,7 +665,7 @@ export class DeesInputWysiwyg extends DeesInputBase { } - private handleTextSelection(e: MouseEvent): void { + private handleTextSelection(_e: MouseEvent): void { // Don't interfere with slash menu if (this.slashMenu.visible) return; @@ -960,69 +879,4 @@ export class DeesInputWysiwyg extends DeesInputBase { }, 100); }); } - - // Modal methods moved to WysiwygModalManager - private async showLanguageSelectionModal(): Promise { - return new Promise((resolve) => { - let selectedLanguage: string | null = null; - - DeesModal.createAndShow({ - heading: 'Select Programming Language', - content: html` - -
- ${['JavaScript', 'TypeScript', 'Python', 'Java', 'C++', 'C#', 'Go', 'Rust', 'HTML', 'CSS', 'SQL', 'Shell', 'JSON', 'YAML', 'Markdown', 'Plain Text'].map(lang => html` -
${lang}
- `)} -
- `, - menuOptions: [ - { - name: 'Cancel', - action: async (modal) => { - modal.destroy(); - resolve(null); - } - }, - { - name: 'OK', - action: async (modal) => { - modal.destroy(); - resolve(selectedLanguage); - } - } - ] - }); - }); - } - - // Modal methods have been moved to WysiwygModalManager } \ 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 30502ca..6b9b6ef 100644 --- a/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts +++ b/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts @@ -1,18 +1,17 @@ import { customElement, property, - html, + static as html, DeesElement, type TemplateResult, cssManager, css, query, - unsafeStatic, - static as staticHtml, } from '@design.estate/dees-element'; import { type IBlock } from './wysiwyg.types.js'; import { WysiwygBlocks } from './wysiwyg.blocks.js'; +import { WysiwygSelection } from './wysiwyg.selection.js'; declare global { interface HTMLElementTagNameMap { @@ -271,10 +270,71 @@ export class DeesWysiwygBlock extends DeesElement { // Mark that content has been initialized this.contentInitialized = true; + // For code blocks, the actual contenteditable block is nested + const editableBlock = this.block.type === 'code' + ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement + : this.blockElement; + // Ensure the block element maintains its content - if (this.blockElement) { - this.blockElement.setAttribute('data-block-id', this.block.id); - this.blockElement.setAttribute('data-block-type', this.block.type); + if (editableBlock) { + editableBlock.setAttribute('data-block-id', this.block.id); + editableBlock.setAttribute('data-block-type', this.block.type); + + // Set up all event handlers manually to avoid Lit re-renders + editableBlock.addEventListener('input', (e) => { + this.logCursorPosition('input'); + this.handlers?.onInput?.(e as InputEvent); + }); + + editableBlock.addEventListener('keydown', (e) => { + this.handlers?.onKeyDown?.(e); + }); + + editableBlock.addEventListener('keyup', (e) => { + this.logCursorPosition('keyup', e); + }); + + editableBlock.addEventListener('focus', () => { + this.handlers?.onFocus?.(); + }); + + editableBlock.addEventListener('blur', () => { + this.handlers?.onBlur?.(); + }); + + editableBlock.addEventListener('compositionstart', () => { + this.handlers?.onCompositionStart?.(); + }); + + editableBlock.addEventListener('compositionend', () => { + this.handlers?.onCompositionEnd?.(); + }); + + editableBlock.addEventListener('mouseup', (e) => { + this.logCursorPosition('mouseup'); + this.handleMouseUp(e); + this.handlers?.onMouseUp?.(e); + }); + + editableBlock.addEventListener('click', () => { + this.logCursorPosition('click'); + }); + + // Set initial content if needed + if (this.block.content) { + if (this.block.type === 'code') { + editableBlock.textContent = this.block.content; + } else if (this.block.type === 'list') { + editableBlock.innerHTML = WysiwygBlocks.renderListContent(this.block.content, this.block.metadata); + } else { + editableBlock.innerHTML = this.block.content; + } + } + } + + // Update blockElement reference for code blocks + if (this.block.type === 'code') { + this.blockElement = editableBlock; } } @@ -298,41 +358,20 @@ export class DeesWysiwygBlock extends DeesElement { class="block code ${this.isSelected ? 'selected' : ''}" contenteditable="true" data-block-type="${this.block.type}" - @input="${this.handlers?.onInput}" - @keydown="${this.handlers?.onKeyDown}" - @focus="${this.handlers?.onFocus}" - @blur="${this.handlers?.onBlur}" - @compositionstart="${this.handlers?.onCompositionStart}" - @compositionend="${this.handlers?.onCompositionEnd}" - @mouseup="${(e: MouseEvent) => { - this.handleMouseUp(e); - this.handlers?.onMouseUp?.(e); - }}" - .textContent="${this.block.content || ''}" > `; } const placeholder = this.getPlaceholder(); - const initialContent = this.getInitialContent(); - return staticHtml` + // Return static HTML without event bindings + return html`
${unsafeStatic(initialContent)}
+ > `; } @@ -353,12 +392,6 @@ export class DeesWysiwygBlock extends DeesElement { } } - private getInitialContent(): string { - if (this.block.type === 'list') { - return WysiwygBlocks.renderListContent(this.block.content, this.block.metadata); - } - return this.block.content || ''; - } public focus(): void { if (!this.blockElement) return; @@ -391,34 +424,13 @@ export class DeesWysiwygBlock extends DeesElement { // Set cursor position after focus is established const setCursor = () => { - const selection = window.getSelection(); - if (!selection) return; - if (position === 'start') { this.setCursorToStart(); } else if (position === 'end') { this.setCursorToEnd(); } else if (typeof position === 'number') { - // Set cursor at specific position - const range = document.createRange(); - const textNode = this.getFirstTextNode(this.blockElement); - - if (textNode) { - const length = textNode.textContent?.length || 0; - const safePosition = Math.min(position, length); - range.setStart(textNode, safePosition); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - } else if (this.blockElement.childNodes.length === 0) { - // Empty block - create a text node - const emptyText = document.createTextNode(''); - this.blockElement.appendChild(emptyText); - range.setStart(emptyText, 0); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - } + // Use the new selection utility to set cursor position + WysiwygSelection.setCursorPosition(this.blockElement, position); } }; @@ -501,47 +513,121 @@ export class DeesWysiwygBlock extends DeesElement { public getSplitContent(): { before: string; after: string } | null { if (!this.blockElement) return null; - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { + // Get the full content first + const fullContent = this.getContent(); + console.log('getSplitContent: Full content:', { + content: fullContent, + length: fullContent.length, + blockType: this.block.type + }); + + // Get selection info using the new utility that handles Shadow DOM + const selectionInfo = WysiwygSelection.getSelectionInfo(this.shadowRoot!); + if (!selectionInfo) { + console.log('getSplitContent: No selection, returning all content as before'); return { - before: this.getContent(), + before: fullContent, after: '' }; } - const range = selection.getRangeAt(0); - // Check if selection is within this block - if (!this.blockElement.contains(range.commonAncestorContainer)) { + if (!WysiwygSelection.isSelectionInElement(this.blockElement, this.shadowRoot!)) { + console.log('getSplitContent: Selection not in this block'); return null; } - // Clone the range to extract content before and after cursor - const beforeRange = range.cloneRange(); - beforeRange.selectNodeContents(this.blockElement); - beforeRange.setEnd(range.startContainer, range.startOffset); + // Get cursor position as a number + const cursorPosition = WysiwygSelection.getCursorPositionInElement(this.blockElement, this.shadowRoot!); + console.log('getSplitContent: Cursor position:', { + cursorPosition, + contentLength: fullContent.length, + startContainer: selectionInfo.startContainer, + startOffset: selectionInfo.startOffset, + collapsed: selectionInfo.collapsed + }); - const afterRange = range.cloneRange(); - afterRange.selectNodeContents(this.blockElement); - afterRange.setStart(range.endContainer, range.endOffset); + // Handle special cases for different block types + if (this.block.type === 'code') { + // For code blocks, split text content + const fullText = this.blockElement.textContent || ''; + const textNode = this.getFirstTextNode(this.blockElement); + + if (textNode && selectionInfo.startContainer === textNode) { + const before = fullText.substring(0, selectionInfo.startOffset); + const after = fullText.substring(selectionInfo.startOffset); + + console.log('getSplitContent: Code block split result:', { + cursorPosition, + contentLength: fullText.length, + beforeContent: before, + beforeLength: before.length, + afterContent: after, + afterLength: after.length, + startOffset: selectionInfo.startOffset + }); + + return { before, after }; + } + } - // Extract content - const beforeFragment = beforeRange.cloneContents(); - const afterFragment = afterRange.cloneContents(); - - // Convert to HTML - const tempDiv = document.createElement('div'); - tempDiv.appendChild(beforeFragment); - const beforeHtml = tempDiv.innerHTML; - - tempDiv.innerHTML = ''; - tempDiv.appendChild(afterFragment); - const afterHtml = tempDiv.innerHTML; - - return { - before: beforeHtml, - after: afterHtml - }; + // For other block types, extract HTML content + try { + // Create a temporary range to get content before cursor + const beforeRange = document.createRange(); + beforeRange.selectNodeContents(this.blockElement); + beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset); + + // Create a temporary range to get content after cursor + const afterRange = document.createRange(); + afterRange.selectNodeContents(this.blockElement); + afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset); + + // Clone HTML content (not extract, to avoid modifying the DOM) + const beforeContents = beforeRange.cloneContents(); + const afterContents = afterRange.cloneContents(); + + // Convert to HTML strings + const tempDiv = document.createElement('div'); + tempDiv.appendChild(beforeContents); + const beforeHtml = tempDiv.innerHTML; + + tempDiv.innerHTML = ''; + tempDiv.appendChild(afterContents); + const afterHtml = tempDiv.innerHTML; + + const result = { + before: beforeHtml, + after: afterHtml + }; + + console.log('getSplitContent: Split result:', { + cursorPosition, + contentLength: fullContent.length, + beforeContent: result.before, + beforeLength: result.before.length, + afterContent: result.after, + afterLength: result.after.length + }); + + return result; + } catch (error) { + console.error('Error splitting content:', error); + // Fallback: return all content as "before" + const fallbackResult = { + before: this.getContent(), + after: '' + }; + + console.log('getSplitContent: Fallback result:', { + beforeContent: fallbackResult.before, + beforeLength: fallbackResult.before.length, + afterContent: fallbackResult.after, + afterLength: fallbackResult.after.length + }); + + return fallbackResult; + } } private handleMouseUp(_e: MouseEvent): void { @@ -570,4 +656,117 @@ export class DeesWysiwygBlock extends DeesElement { } }, 10); } + + /** + * Logs cursor position for debugging + */ + private logCursorPosition(eventType: string, event?: KeyboardEvent): void { + console.log(`[CursorLog] Event triggered: ${eventType} in block ${this.block.id}`); + + // Get the actual active element considering shadow DOM + const activeElement = this.shadowRoot?.activeElement; + console.log(`[CursorLog] Active element:`, activeElement, 'Block element:', this.blockElement); + + // Only log if this block is focused + if (activeElement !== this.blockElement) { + console.log(`[CursorLog] Block not focused, skipping detailed logging`); + return; + } + + // Get selection info using the new utility that handles Shadow DOM + const selectionInfo = WysiwygSelection.getSelectionInfo(this.shadowRoot!); + if (!selectionInfo) { + console.log(`[${eventType}] No selection available`); + return; + } + + const isInThisBlock = WysiwygSelection.isSelectionInElement(this.blockElement, this.shadowRoot!); + + if (!isInThisBlock) { + return; + } + + // Get cursor position details + const details: any = { + event: eventType, + blockId: this.block.id, + blockType: this.block.type, + collapsed: selectionInfo.collapsed, + startContainer: { + nodeType: selectionInfo.startContainer.nodeType, + nodeName: selectionInfo.startContainer.nodeName, + textContent: selectionInfo.startContainer.textContent?.substring(0, 50) + '...', + }, + startOffset: selectionInfo.startOffset, + }; + + // Add key info if it's a keyboard event + if (event) { + details.key = event.key; + details.shiftKey = event.shiftKey; + details.ctrlKey = event.ctrlKey; + details.metaKey = event.metaKey; + } + + // Try to get the actual cursor position in the text + if (selectionInfo.startContainer.nodeType === Node.TEXT_NODE) { + const textNode = selectionInfo.startContainer as Text; + const textBefore = textNode.textContent?.substring(0, selectionInfo.startOffset) || ''; + const textAfter = textNode.textContent?.substring(selectionInfo.startOffset) || ''; + + details.cursorPosition = { + textBefore: textBefore.slice(-20), // Last 20 chars before cursor + textAfter: textAfter.slice(0, 20), // First 20 chars after cursor + totalLength: textNode.textContent?.length || 0, + offset: selectionInfo.startOffset + }; + } + + // Check if we're at boundaries + details.boundaries = { + atStart: this.isCursorAtStart(selectionInfo), + atEnd: this.isCursorAtEnd(selectionInfo) + }; + + console.log('Cursor Position:', details); + } + + /** + * Check if cursor is at the start of the block + */ + private isCursorAtStart(selectionInfo: { startContainer: Node; startOffset: number; collapsed: boolean }): boolean { + if (!selectionInfo.collapsed || selectionInfo.startOffset !== 0) return false; + + const firstNode = this.getFirstTextNode(this.blockElement); + return !firstNode || selectionInfo.startContainer === firstNode || selectionInfo.startContainer === this.blockElement; + } + + /** + * Check if cursor is at the end of the block + */ + private isCursorAtEnd(selectionInfo: { endContainer: Node; endOffset: number; collapsed: boolean }): boolean { + if (!selectionInfo.collapsed) return false; + + const lastNode = this.getLastTextNode(this.blockElement); + if (!lastNode) return true; + + return selectionInfo.endContainer === lastNode && + selectionInfo.endOffset === (lastNode.textContent?.length || 0); + } + + /** + * Get the last text node in the element + */ + private getLastTextNode(node: Node): Text | null { + if (node.nodeType === Node.TEXT_NODE) { + return node as Text; + } + + for (let i = node.childNodes.length - 1; i >= 0; i--) { + const lastText = this.getLastTextNode(node.childNodes[i]); + if (lastText) return lastText; + } + + return null; + } } \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/index.ts b/ts_web/elements/wysiwyg/index.ts index 4064847..b0c13c7 100644 --- a/ts_web/elements/wysiwyg/index.ts +++ b/ts_web/elements/wysiwyg/index.ts @@ -1,10 +1,12 @@ export * from './wysiwyg.types.js'; export * from './wysiwyg.interfaces.js'; +export * from './wysiwyg.constants.js'; export * from './wysiwyg.styles.js'; export * from './wysiwyg.converters.js'; export * from './wysiwyg.shortcuts.js'; export * from './wysiwyg.blocks.js'; export * from './wysiwyg.formatting.js'; +export * from './wysiwyg.selection.js'; export * from './wysiwyg.blockoperations.js'; export * from './wysiwyg.inputhandler.js'; export * from './wysiwyg.keyboardhandler.js'; diff --git a/ts_web/elements/wysiwyg/instructions.md b/ts_web/elements/wysiwyg/instructions.md index 8fe46fd..f6240d8 100644 --- a/ts_web/elements/wysiwyg/instructions.md +++ b/ts_web/elements/wysiwyg/instructions.md @@ -1,4 +1,5 @@ -* We don't use lit template logic, but use static`` here to handle dom operations ourselves +* We don't use lit html logic, no event binding, no nothing, but only use static`` here to handle dom operations ourselves * We try to have separated concerns in different classes * We try to have clean concise and managable code -* lets log whats happening, so if something goes wrong, we understand whats happening. \ No newline at end of file +* lets log whats happening, so if something goes wrong, we understand whats happening. +* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/wysiwyg.blockoperations.ts b/ts_web/elements/wysiwyg/wysiwyg.blockoperations.ts index 1846e30..4a16494 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.blockoperations.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.blockoperations.ts @@ -1,11 +1,12 @@ import { type IBlock } from './wysiwyg.types.js'; +import { type IWysiwygComponent } from './wysiwyg.interfaces.js'; import { WysiwygShortcuts } from './wysiwyg.shortcuts.js'; import { WysiwygBlocks } from './wysiwyg.blocks.js'; export class WysiwygBlockOperations { - private component: any; // Will be typed properly when imported + private component: IWysiwygComponent; - constructor(component: any) { + constructor(component: IWysiwygComponent) { this.component = component; } diff --git a/ts_web/elements/wysiwyg/wysiwyg.constants.ts b/ts_web/elements/wysiwyg/wysiwyg.constants.ts new file mode 100644 index 0000000..e7c3f72 --- /dev/null +++ b/ts_web/elements/wysiwyg/wysiwyg.constants.ts @@ -0,0 +1,27 @@ +/** + * Shared constants for the WYSIWYG editor + */ + +/** + * Available programming languages for code blocks + */ +export const PROGRAMMING_LANGUAGES = [ + 'JavaScript', + 'TypeScript', + 'Python', + 'Java', + 'C++', + 'C#', + 'Go', + 'Rust', + 'HTML', + 'CSS', + 'SQL', + 'Shell', + 'JSON', + 'YAML', + 'Markdown', + 'Plain Text' +] as const; + +export type ProgrammingLanguage = typeof PROGRAMMING_LANGUAGES[number]; \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/wysiwyg.dragdrophandler.ts b/ts_web/elements/wysiwyg/wysiwyg.dragdrophandler.ts index 1dcc535..a80f13a 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.dragdrophandler.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.dragdrophandler.ts @@ -1,7 +1,8 @@ import { type IBlock } from './wysiwyg.types.js'; +import { type IWysiwygComponent } from './wysiwyg.interfaces.js'; export class WysiwygDragDropHandler { - private component: any; + private component: IWysiwygComponent; private draggedBlockId: string | null = null; private dragOverBlockId: string | null = null; private dragOverPosition: 'before' | 'after' | null = null; @@ -13,7 +14,7 @@ export class WysiwygDragDropHandler { private lastUpdateTime: number = 0; private updateThrottle: number = 80; // milliseconds - constructor(component: any) { + constructor(component: IWysiwygComponent) { this.component = component; } diff --git a/ts_web/elements/wysiwyg/wysiwyg.formatting.ts b/ts_web/elements/wysiwyg/wysiwyg.formatting.ts index a01867e..46f136d 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.formatting.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.formatting.ts @@ -1,4 +1,5 @@ import { html, type TemplateResult } from '@design.estate/dees-element'; +import { WysiwygSelection } from './wysiwyg.selection.js'; export interface IFormatButton { command: string; @@ -143,20 +144,20 @@ export class WysiwygFormatting { } static getSelectionCoordinates(shadowRoot?: ShadowRoot): { x: number, y: number } | null { - // Try shadow root selection first, then window - let selection = shadowRoot && (shadowRoot as any).getSelection ? (shadowRoot as any).getSelection() : null; - if (!selection || selection.rangeCount === 0) { - selection = window.getSelection(); - } + // Get selection info using the new utility that handles Shadow DOM + const selectionInfo = shadowRoot + ? WysiwygSelection.getSelectionInfo(shadowRoot) + : WysiwygSelection.getSelectionInfo(); - console.log('getSelectionCoordinates - selection:', selection); + console.log('getSelectionCoordinates - selectionInfo:', selectionInfo); - if (!selection || selection.rangeCount === 0) { - console.log('No selection or no ranges'); + if (!selectionInfo) { + console.log('No selection info available'); return null; } - const range = selection.getRangeAt(0); + // Create a range from the selection info to get bounding rect + const range = WysiwygSelection.createRangeFromInfo(selectionInfo); const rect = range.getBoundingClientRect(); console.log('Range rect:', rect); @@ -174,57 +175,4 @@ export class WysiwygFormatting { console.log('Returning coords:', coords); return coords; } - - static isFormattingApplied(command: string): boolean { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return false; - - const range = selection.getRangeAt(0); - const container = range.commonAncestorContainer; - const element = container.nodeType === Node.TEXT_NODE - ? container.parentElement - : container as Element; - - if (!element) return false; - - // Check if formatting is applied by looking at parent elements - switch (command) { - case 'bold': - return !!element.closest('b, strong'); - case 'italic': - return !!element.closest('i, em'); - case 'underline': - return !!element.closest('u'); - case 'strikeThrough': - return !!element.closest('s, strike'); - case 'code': - return !!element.closest('code'); - case 'link': - return !!element.closest('a'); - default: - return false; - } - } - - static hasSelection(): boolean { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - console.log('No selection or no ranges'); - return false; - } - - // Check if we actually have selected text (not just collapsed cursor) - const selectedText = selection.toString(); - if (!selectedText || selectedText.length === 0) { - console.log('No text selected'); - return false; - } - - return true; - } - - static getSelectedText(): string { - const selection = window.getSelection(); - return selection ? selection.toString() : ''; - } } \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/wysiwyg.inputhandler.ts b/ts_web/elements/wysiwyg/wysiwyg.inputhandler.ts index b3c0fcc..1b51878 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.inputhandler.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.inputhandler.ts @@ -1,14 +1,15 @@ import { type IBlock } from './wysiwyg.types.js'; +import { type IWysiwygComponent } from './wysiwyg.interfaces.js'; import { WysiwygShortcuts } from './wysiwyg.shortcuts.js'; import { WysiwygBlocks } from './wysiwyg.blocks.js'; import { WysiwygBlockOperations } from './wysiwyg.blockoperations.js'; import { WysiwygModalManager } from './wysiwyg.modalmanager.js'; export class WysiwygInputHandler { - private component: any; + private component: IWysiwygComponent; private saveTimeout: any = null; - constructor(component: any) { + constructor(component: IWysiwygComponent) { this.component = component; } @@ -174,6 +175,11 @@ export class WysiwygInputHandler { if (this.component.editorContentRef) { this.component.updateBlockElement(block.id); } + + // Focus the code block + setTimeout(async () => { + await blockOps.focusBlock(block.id, 'start'); + }, 50); } } else { block.type = detectedType.type; @@ -186,6 +192,11 @@ export class WysiwygInputHandler { if (this.component.editorContentRef) { this.component.updateBlockElement(block.id); } + + // Focus the transformed block + setTimeout(async () => { + await blockOps.focusBlock(block.id, 'start'); + }, 50); } } diff --git a/ts_web/elements/wysiwyg/wysiwyg.interfaces.ts b/ts_web/elements/wysiwyg/wysiwyg.interfaces.ts index 38a1637..c27b4d2 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.interfaces.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.interfaces.ts @@ -11,6 +11,11 @@ export interface IWysiwygComponent { blocks: IBlock[]; selectedBlockId: string | null; shadowRoot: ShadowRoot | null; + editorContentRef: HTMLDivElement; + draggedBlockId: string | null; + dragOverBlockId: string | null; + dragOverPosition: 'before' | 'after' | null; + isComposing: boolean; // Menus slashMenu: DeesSlashMenu; @@ -18,12 +23,16 @@ export interface IWysiwygComponent { // Methods updateValue(): void; - requestUpdate(): Promise; + requestUpdate(): void; updateComplete: Promise; insertBlock(type: string): Promise; closeSlashMenu(clearSlash?: boolean): void; applyFormat(command: string): Promise; handleSlashMenuKeyboard(e: KeyboardEvent): void; + createBlockElement(block: IBlock): HTMLElement; + updateBlockElement(blockId: string): void; + handleDrop(e: DragEvent, targetBlock: IBlock): void; + renderBlocksProgrammatically(): void; // Handlers blockOperations: IBlockOperations; @@ -44,7 +53,6 @@ export interface IBlockOperations { moveBlock(blockId: string, targetIndex: number): void; getPreviousBlock(blockId: string): IBlock | null; getNextBlock(blockId: string): IBlock | null; - splitBlock(blockId: string, splitPosition: number): Promise; } /** diff --git a/ts_web/elements/wysiwyg/wysiwyg.keyboardhandler.ts b/ts_web/elements/wysiwyg/wysiwyg.keyboardhandler.ts index cad1be4..c60aea1 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.keyboardhandler.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.keyboardhandler.ts @@ -1,9 +1,10 @@ import { type IBlock } from './wysiwyg.types.js'; +import { type IWysiwygComponent } from './wysiwyg.interfaces.js'; export class WysiwygKeyboardHandler { - private component: any; + private component: IWysiwygComponent; - constructor(component: any) { + constructor(component: IWysiwygComponent) { this.component = component; } @@ -132,34 +133,59 @@ export class WysiwygKeyboardHandler { // Split content at cursor position e.preventDefault(); - // Get the block component - const target = e.target as HTMLElement; - const blockWrapper = target.closest('.block-wrapper'); + console.log('Enter key pressed in block:', { + blockId: block.id, + blockType: block.type, + blockContent: block.content, + blockContentLength: block.content?.length || 0, + eventTarget: e.target, + eventTargetTagName: (e.target as HTMLElement).tagName + }); + + // Get the block component - need to search in the wysiwyg component's shadow DOM + const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`); + console.log('Found block wrapper:', blockWrapper); + const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any; + console.log('Found block component:', blockComponent, 'has getSplitContent:', !!blockComponent?.getSplitContent); if (blockComponent && blockComponent.getSplitContent) { + console.log('Calling getSplitContent...'); const splitContent = blockComponent.getSplitContent(); + console.log('Enter key split content result:', { + hasSplitContent: !!splitContent, + beforeLength: splitContent?.before?.length || 0, + afterLength: splitContent?.after?.length || 0, + splitContent + }); + if (splitContent) { + console.log('Updating current block with before content...'); // Update current block with content before cursor blockComponent.setContent(splitContent.before); block.content = splitContent.before; + console.log('Creating new block with after content...'); // Create new block with content after cursor const newBlock = blockOps.createBlock('paragraph', splitContent.after); + console.log('Inserting new block...'); // Insert the new block await blockOps.insertBlockAfter(block, newBlock); // Update the value after both blocks are set this.component.updateValue(); + console.log('Enter key handling complete'); } else { // Fallback - just create empty block + console.log('No split content returned, creating empty block'); const newBlock = blockOps.createBlock(); await blockOps.insertBlockAfter(block, newBlock); } } else { // No block component or method, just create empty block + console.log('No getSplitContent method, creating empty block'); const newBlock = blockOps.createBlock(); await blockOps.insertBlockAfter(block, newBlock); } diff --git a/ts_web/elements/wysiwyg/wysiwyg.modalmanager.ts b/ts_web/elements/wysiwyg/wysiwyg.modalmanager.ts index cd50306..3814869 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.modalmanager.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.modalmanager.ts @@ -2,6 +2,7 @@ import { html, type TemplateResult } from '@design.estate/dees-element'; import { DeesModal } from '../dees-modal.js'; import { type IBlock } from './wysiwyg.types.js'; import { WysiwygShortcuts } from './wysiwyg.shortcuts.js'; +import { PROGRAMMING_LANGUAGES } from './wysiwyg.constants.js'; export class WysiwygModalManager { /** @@ -207,11 +208,7 @@ export class WysiwygModalManager { * Gets available programming languages */ private static getLanguages(): string[] { - return [ - 'JavaScript', 'TypeScript', 'Python', 'Java', - 'C++', 'C#', 'Go', 'Rust', 'HTML', 'CSS', - 'SQL', 'Shell', 'JSON', 'YAML', 'Markdown', 'Plain Text' - ]; + return [...PROGRAMMING_LANGUAGES]; } /** diff --git a/ts_web/elements/wysiwyg/wysiwyg.selection.ts b/ts_web/elements/wysiwyg/wysiwyg.selection.ts new file mode 100644 index 0000000..37d9462 --- /dev/null +++ b/ts_web/elements/wysiwyg/wysiwyg.selection.ts @@ -0,0 +1,157 @@ +/** + * Utilities for handling selection across Shadow DOM boundaries + */ + +export interface SelectionInfo { + startContainer: Node; + startOffset: number; + endContainer: Node; + endOffset: number; + collapsed: boolean; +} + +export class WysiwygSelection { + /** + * Gets selection info that works across Shadow DOM boundaries + * @param shadowRoots - Shadow roots to include in the selection search + */ + static getSelectionInfo(...shadowRoots: ShadowRoot[]): SelectionInfo | null { + const selection = window.getSelection(); + if (!selection) return null; + + // Try using getComposedRanges if available (better Shadow DOM support) + if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function') { + try { + const ranges = selection.getComposedRanges(...shadowRoots); + if (ranges.length > 0) { + const range = ranges[0]; + return { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: range.collapsed + }; + } + } catch (error) { + console.warn('getComposedRanges failed, falling back to getRangeAt:', error); + } + } + + // Fallback to traditional selection API + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + return { + startContainer: range.startContainer, + startOffset: range.startOffset, + endContainer: range.endContainer, + endOffset: range.endOffset, + collapsed: range.collapsed + }; + } + + return null; + } + + /** + * Checks if a selection is within a specific element (considering Shadow DOM) + */ + static isSelectionInElement(element: Element, shadowRoot?: ShadowRoot): boolean { + const selectionInfo = shadowRoot + ? this.getSelectionInfo(shadowRoot) + : this.getSelectionInfo(); + + if (!selectionInfo) return false; + + // Check if the selection's common ancestor is within the element + return element.contains(selectionInfo.startContainer) || + element.contains(selectionInfo.endContainer); + } + + /** + * Gets the selected text across Shadow DOM boundaries + */ + static getSelectedText(): string { + const selection = window.getSelection(); + return selection ? selection.toString() : ''; + } + + /** + * Creates a range from selection info + */ + static createRangeFromInfo(info: SelectionInfo): Range { + const range = document.createRange(); + range.setStart(info.startContainer, info.startOffset); + range.setEnd(info.endContainer, info.endOffset); + return range; + } + + /** + * Sets selection from a range (works with Shadow DOM) + */ + static setSelectionFromRange(range: Range): void { + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } + } + + /** + * Gets cursor position relative to a specific element + */ + static getCursorPositionInElement(element: Element, shadowRoot?: ShadowRoot): number | null { + const selectionInfo = shadowRoot + ? this.getSelectionInfo(shadowRoot) + : this.getSelectionInfo(); + + if (!selectionInfo || !selectionInfo.collapsed) return null; + + // Create a range from start of element to cursor position + try { + const range = document.createRange(); + range.selectNodeContents(element); + range.setEnd(selectionInfo.startContainer, selectionInfo.startOffset); + + return range.toString().length; + } catch (error) { + console.warn('Failed to get cursor position:', error); + return null; + } + } + + /** + * Sets cursor position in an element + */ + static setCursorPosition(element: Element, position: number): void { + const walker = document.createTreeWalker( + element, + NodeFilter.SHOW_TEXT, + null + ); + + let currentPosition = 0; + let targetNode: Text | null = null; + let targetOffset = 0; + + while (walker.nextNode()) { + const node = walker.currentNode as Text; + const nodeLength = node.textContent?.length || 0; + + if (currentPosition + nodeLength >= position) { + targetNode = node; + targetOffset = position - currentPosition; + break; + } + + currentPosition += nodeLength; + } + + if (targetNode) { + const range = document.createRange(); + range.setStart(targetNode, targetOffset); + range.collapse(true); + this.setSelectionFromRange(range); + } + } +} \ No newline at end of file