diff --git a/ts_web/elements/wysiwyg/dees-formatting-menu.ts b/ts_web/elements/wysiwyg/dees-formatting-menu.ts index 14682e3..3755724 100644 --- a/ts_web/elements/wysiwyg/dees-formatting-menu.ts +++ b/ts_web/elements/wysiwyg/dees-formatting-menu.ts @@ -162,9 +162,11 @@ export class DeesFormattingMenu extends DeesElement { } public show(position: { x: number; y: number }, callback: (command: string) => void | Promise): void { + console.log('FormattingMenu.show called:', { position, visible: this.visible }); this.position = position; this.callback = callback; this.visible = true; + console.log('FormattingMenu.show - visible set to:', this.visible); } public hide(): void { diff --git a/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts index 18ccb91..23d94f6 100644 --- a/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts +++ b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts @@ -24,6 +24,7 @@ import { WysiwygDragDropHandler, WysiwygModalManager, WysiwygHistory, + WysiwygSelection, DeesSlashMenu, DeesFormattingMenu } from './index.js'; @@ -138,11 +139,38 @@ export class DeesInputWysiwyg extends DeesInputBase { console.log('Adding selectionchange listener'); document.addEventListener('selectionchange', this.selectionChangeHandler); + // Also add listener to our shadow root if supported + if (this.shadowRoot && 'addEventListener' in this.shadowRoot) { + this.shadowRoot.addEventListener('selectionchange', this.selectionChangeHandler); + } + // Listen for custom selection events from blocks this.addEventListener('block-text-selected', (e: CustomEvent) => { + console.log('Received block-text-selected event:', e.detail); + if (!this.slashMenu.visible) { - this.selectedText = e.detail.text; - this.updateFormattingMenuPosition(); + if (e.detail.hasSelection && e.detail.text.length > 0) { + this.selectedText = e.detail.text; + + // Use the rect from the event if available + if (e.detail.rect) { + const coords = { + x: e.detail.rect.left + (e.detail.rect.width / 2), + y: Math.max(45, e.detail.rect.top - 45) + }; + + // Show the formatting menu at the calculated position + this.formattingMenu.show( + coords, + async (command: string) => await this.applyFormat(command) + ); + } else { + this.updateFormattingMenuPosition(); + } + } else { + // Clear selection + this.hideFormattingMenu(); + } } }); @@ -700,72 +728,157 @@ export class DeesInputWysiwyg extends DeesInputBase { } private handleSelectionChange(): void { - // Try to get selection from shadow root first, then fall back to window - const shadowSelection = (this.shadowRoot as any).getSelection ? (this.shadowRoot as any).getSelection() : null; - const windowSelection = window.getSelection(); - const editorContent = this.shadowRoot?.querySelector('.editor-content') as HTMLElement; + console.log('=== handleSelectionChange called ==='); - // Check both shadow and window selections - let selection = shadowSelection; - let selectedText = shadowSelection?.toString() || ''; - - // If no shadow selection, check window selection - if (!selectedText && windowSelection) { - selection = windowSelection; - selectedText = windowSelection.toString() || ''; - } - - console.log('Selection change:', { - hasText: selectedText.length > 0, - selectedText: selectedText.substring(0, 50), - shadowSelection: !!shadowSelection, - windowSelection: !!windowSelection, - rangeCount: selection?.rangeCount, - editorContent: !!editorContent - }); - - if (!selection || selection.rangeCount === 0 || !editorContent) { - console.log('No selection or editor content'); + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + if (this.formattingMenu.visible) { + console.log('No selection, hiding menu'); + this.hideFormattingMenu(); + } return; } - // If we have selected text, show the formatting menu - if (selectedText.length > 0) { - console.log('✅ Text selected:', selectedText); - - if (selectedText !== this.selectedText) { - this.selectedText = selectedText; - this.updateFormattingMenuPosition(); + const selectedText = selection.toString().trim(); + console.log('Selected text:', selectedText); + + if (selectedText.length === 0) { + if (this.formattingMenu.visible) { + console.log('No text selected, hiding menu'); + this.hideFormattingMenu(); } - } else if (this.formattingMenu.visible) { - console.log('No text selected, hiding menu'); + return; + } + + // Get all shadow roots in our component tree + const shadowRoots: ShadowRoot[] = []; + if (this.shadowRoot) shadowRoots.push(this.shadowRoot); + + // Find all block shadow roots + const blockWrappers = this.shadowRoot?.querySelectorAll('.block-wrapper'); + console.log('Found block wrappers:', blockWrappers?.length || 0); + + blockWrappers?.forEach(wrapper => { + const blockComponent = wrapper.querySelector('dees-wysiwyg-block'); + if (blockComponent?.shadowRoot) { + shadowRoots.push(blockComponent.shadowRoot); + } + }); + + console.log('Shadow roots collected:', shadowRoots.length); + + // Try using getComposedRanges if available + let ranges: Range[] | StaticRange[] = []; + let selectionInEditor = false; + + if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function') { + console.log('Using getComposedRanges with shadow roots'); + try { + // According to MDN, pass shadow roots in options object + ranges = selection.getComposedRanges({ shadowRoots }); + console.log('getComposedRanges returned', ranges.length, 'ranges'); + + if (ranges.length > 0) { + const range = ranges[0]; + // Check if the range is within our editor + selectionInEditor = this.isRangeInEditor(range); + } + } catch (error) { + console.warn('getComposedRanges failed:', error); + } + } + + // Fallback to regular selection API + if (ranges.length === 0 && selection.rangeCount > 0) { + console.log('Falling back to getRangeAt'); + const range = selection.getRangeAt(0); + ranges = [range]; + selectionInEditor = this.isRangeInEditor(range); + } + + console.log('Selection in editor:', selectionInEditor); + + if (selectionInEditor && selectedText !== this.selectedText) { + console.log('✅ Text selected in editor:', selectedText); + this.selectedText = selectedText; + this.updateFormattingMenuPosition(); + } else if (!selectionInEditor && this.formattingMenu.visible) { + console.log('Selection not in editor, hiding menu'); this.hideFormattingMenu(); } } + private isRangeInEditor(range: Range | StaticRange): boolean { + // Check if the selection is within one of our blocks + const blockWrappers = this.shadowRoot?.querySelectorAll('.block-wrapper'); + if (!blockWrappers) return false; + + // Check each block + for (let i = 0; i < blockWrappers.length; i++) { + const wrapper = blockWrappers[i]; + const blockComponent = wrapper.querySelector('dees-wysiwyg-block'); + if (blockComponent?.shadowRoot) { + const editableElements = blockComponent.shadowRoot.querySelectorAll('.block, .block.code'); + for (let j = 0; j < editableElements.length; j++) { + const elem = editableElements[j]; + if (elem) { + // For StaticRange, we need to check differently + if ('startContainer' in range) { + // Check if the range nodes are within this element + const startInElement = this.isNodeInElement(range.startContainer, elem); + const endInElement = this.isNodeInElement(range.endContainer, elem); + + if (startInElement || endInElement) { + console.log('Selection found in block:', wrapper.getAttribute('data-block-id')); + return true; + } + } + } + } + } + } + + console.log('Selection not in any block. Range:', range); + return false; + } + + /** + * Check if a node is within an element (handles shadow DOM) + */ + private isNodeInElement(node: Node, element: Element): boolean { + let current: Node | null = node; + while (current) { + if (current === element) return true; + // Walk up the tree, including shadow host if in shadow DOM + current = current.parentNode || (current as any).host; + } + return false; + } + private updateFormattingMenuPosition(): void { console.log('updateFormattingMenuPosition called'); - const coords = WysiwygFormatting.getSelectionCoordinates(this.shadowRoot as ShadowRoot); + + // Get all shadow roots + const shadowRoots: ShadowRoot[] = []; + if (this.shadowRoot) shadowRoots.push(this.shadowRoot); + + // Find all block shadow roots + const blockWrappers = this.shadowRoot?.querySelectorAll('.block-wrapper'); + blockWrappers?.forEach(wrapper => { + const blockComponent = wrapper.querySelector('dees-wysiwyg-block'); + if (blockComponent?.shadowRoot) { + shadowRoots.push(blockComponent.shadowRoot); + } + }); + + const coords = WysiwygFormatting.getSelectionCoordinates(...shadowRoots); console.log('Selection coordinates:', coords); if (coords) { - const container = this.shadowRoot!.querySelector('.wysiwyg-container'); - if (!container) { - console.error('Container not found!'); - return; - } - - const containerRect = container.getBoundingClientRect(); - const formattingMenuPosition = { - x: coords.x - containerRect.left, - y: coords.y - containerRect.top - }; - - console.log('Setting menu position:', formattingMenuPosition); - // Show the global formatting menu + // Show the global formatting menu at absolute coordinates this.formattingMenu.show( - { x: coords.x, y: coords.y }, // Use absolute coordinates + { x: coords.x, y: coords.y }, async (command: string) => await this.applyFormat(command) ); } else { @@ -779,33 +892,56 @@ export class DeesInputWysiwyg extends DeesInputBase { } public async applyFormat(command: string): Promise { - // Save current selection before applying format - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return; - - // Get the current block - const anchorNode = selection.anchorNode; - const blockElement = anchorNode?.nodeType === Node.TEXT_NODE - ? anchorNode.parentElement?.closest('.block') - : (anchorNode as Element)?.closest('.block'); + // Get all shadow roots + const shadowRoots: ShadowRoot[] = []; + if (this.shadowRoot) shadowRoots.push(this.shadowRoot); - if (!blockElement) return; - - const blockWrapper = blockElement.closest('.block-wrapper'); - const blockId = blockWrapper?.getAttribute('data-block-id'); - const block = this.blocks.find(b => b.id === blockId); - const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any; + // Find all block shadow roots + const blockWrappers = this.shadowRoot?.querySelectorAll('.block-wrapper'); + blockWrappers?.forEach(wrapper => { + const blockComponent = wrapper.querySelector('dees-wysiwyg-block'); + if (blockComponent?.shadowRoot) { + shadowRoots.push(blockComponent.shadowRoot); + } + }); - if (!block || !blockComponent) return; + // Get selection info using Shadow DOM-aware utilities + const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots); + if (!selectionInfo) return; + + // Find which block contains the selection + let targetBlock: IBlock | undefined; + let targetBlockComponent: any; + + const wrappers = this.shadowRoot!.querySelectorAll('.block-wrapper'); + for (let i = 0; i < wrappers.length; i++) { + const wrapper = wrappers[i]; + const blockComponent = wrapper.querySelector('dees-wysiwyg-block') as any; + if (blockComponent?.shadowRoot) { + const block = blockComponent.shadowRoot.querySelector('.block'); + if (block && ( + block.contains(selectionInfo.startContainer) || + block.contains(selectionInfo.endContainer) + )) { + const blockId = wrapper.getAttribute('data-block-id'); + targetBlock = this.blocks.find(b => b.id === blockId); + targetBlockComponent = blockComponent; + break; + } + } + } + + if (!targetBlock || !targetBlockComponent) return; // Handle link command specially if (command === 'link') { const url = await this.showLinkDialog(); if (!url) { // User cancelled - restore focus to block - blockComponent.focus(); + targetBlockComponent.focus(); return; } + // Apply link format WysiwygFormatting.applyFormat(command, url); } else { // Apply the format @@ -813,10 +949,10 @@ export class DeesInputWysiwyg extends DeesInputBase { } // Update content after a microtask to ensure DOM is updated - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise(resolve => setTimeout(resolve, 10)); // Force content update - block.content = blockComponent.getContent(); + targetBlock.content = targetBlockComponent.getContent(); // Update value to persist changes this.updateValue(); @@ -825,14 +961,9 @@ export class DeesInputWysiwyg extends DeesInputBase { if (command === 'link') { this.hideFormattingMenu(); } else if (this.formattingMenu.visible) { - // Update menu position if still showing + // Keep selection and update menu position this.updateFormattingMenuPosition(); } - - // Ensure block still has focus - if (document.activeElement !== blockElement) { - blockComponent.focus(); - } } private async showLinkDialog(): Promise { diff --git a/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts b/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts index b41fb9c..404c211 100644 --- a/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts +++ b/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts @@ -315,6 +315,13 @@ export class DeesWysiwygBlock extends DeesElement { if (pos !== null) { this.lastKnownCursorPosition = pos; } + + // Check for selection after keyboard navigation + if (e.shiftKey || ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) { + setTimeout(() => { + this.checkForTextSelection(); + }, 10); + } }); editableBlock.addEventListener('focus', () => { @@ -341,6 +348,9 @@ export class DeesWysiwygBlock extends DeesElement { this.lastKnownCursorPosition = pos; console.log('Cursor position after mouseup:', pos); } + + // Check for text selection + this.checkForTextSelection(); }, 0); this.handleMouseUp(e); @@ -778,32 +788,75 @@ export class DeesWysiwygBlock extends DeesElement { } private handleMouseUp(_e: MouseEvent): void { - // Check if we have a selection within this block - setTimeout(() => { - const selection = window.getSelection(); - if (selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - - // Check if selection is within this block - const editableElement = this.block?.type === 'code' - ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement - : this.shadowRoot?.querySelector('.block') as HTMLDivElement; - if (editableElement && editableElement.contains(range.commonAncestorContainer)) { - const selectedText = selection.toString(); - if (selectedText.length > 0) { - // Dispatch a custom event that can cross shadow DOM boundaries - this.dispatchEvent(new CustomEvent('block-text-selected', { - detail: { - text: selectedText, - blockId: this.block.id, - range: range - }, - bubbles: true, - composed: true - })); - } - } - } - }, 10); + // Selection check is now handled in the mouseup event listener + } + + /** + * Check if there's text selected within this block + */ + private checkForTextSelection(): void { + const editableElement = this.block?.type === 'code' + ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement + : this.shadowRoot?.querySelector('.block') as HTMLDivElement; + if (!editableElement) return; + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + // Dispatch event to clear selection + this.dispatchEvent(new CustomEvent('block-text-selected', { + detail: { + text: '', + blockId: this.block.id, + hasSelection: false + }, + bubbles: true, + composed: true + })); + return; + } + + const selectedText = selection.toString().trim(); + + // Only proceed if we have selected text + if (selectedText.length === 0) { + // Dispatch event to clear selection + this.dispatchEvent(new CustomEvent('block-text-selected', { + detail: { + text: '', + blockId: this.block.id, + hasSelection: false + }, + bubbles: true, + composed: true + })); + return; + } + + // Check if the selection is within this block + const range = selection.getRangeAt(0); + + // Check if both start and end are within our editable element + const startInBlock = editableElement.contains(range.startContainer); + const endInBlock = editableElement.contains(range.endContainer); + + if (startInBlock && endInBlock) { + console.log('Block detected text selection:', selectedText); + + // Get the bounding rect of the selection + const rect = range.getBoundingClientRect(); + + // Dispatch event to parent with selection details + this.dispatchEvent(new CustomEvent('block-text-selected', { + detail: { + text: selectedText, + blockId: this.block.id, + range: range, + rect: rect, + hasSelection: true + }, + bubbles: true, + composed: true + })); + } } } \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/wysiwyg.formatting.ts b/ts_web/elements/wysiwyg/wysiwyg.formatting.ts index 46f136d..7aad5d6 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.formatting.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.formatting.ts @@ -143,11 +143,9 @@ export class WysiwygFormatting { } } - static getSelectionCoordinates(shadowRoot?: ShadowRoot): { x: number, y: number } | null { + static getSelectionCoordinates(...shadowRoots: ShadowRoot[]): { x: number, y: number } | null { // Get selection info using the new utility that handles Shadow DOM - const selectionInfo = shadowRoot - ? WysiwygSelection.getSelectionInfo(shadowRoot) - : WysiwygSelection.getSelectionInfo(); + const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots); console.log('getSelectionCoordinates - selectionInfo:', selectionInfo); @@ -162,8 +160,29 @@ export class WysiwygFormatting { console.log('Range rect:', rect); - if (rect.width === 0) { - console.log('Rect width is 0'); + if (rect.width === 0 && rect.height === 0) { + console.log('Rect width and height are 0, trying different approach'); + // Sometimes the rect is collapsed, let's try getting the caret position + if ('caretPositionFromPoint' in document) { + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const tempSpan = document.createElement('span'); + tempSpan.textContent = '\u200B'; // Zero-width space + range.insertNode(tempSpan); + const spanRect = tempSpan.getBoundingClientRect(); + tempSpan.remove(); + + if (spanRect.width > 0 || spanRect.height > 0) { + const coords = { + x: spanRect.left, + y: Math.max(45, spanRect.top - 45) + }; + console.log('Used span trick for coords:', coords); + return coords; + } + } + } return null; } diff --git a/ts_web/elements/wysiwyg/wysiwyg.selection.ts b/ts_web/elements/wysiwyg/wysiwyg.selection.ts index 414ebc7..3948b0b 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.selection.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.selection.ts @@ -20,13 +20,16 @@ export class WysiwygSelection { */ static getSelectionInfo(...shadowRoots: ShadowRoot[]): SelectionInfo | null { const selection = window.getSelection(); + console.log('WysiwygSelection.getSelectionInfo - selection:', selection, 'rangeCount:', selection?.rangeCount); if (!selection) return null; // Try using getComposedRanges if available (better Shadow DOM support) if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function') { + console.log('Using getComposedRanges with', shadowRoots.length, 'shadow roots'); try { // Pass shadow roots in the correct format as per MDN const ranges = selection.getComposedRanges({ shadowRoots }); + console.log('getComposedRanges returned', ranges.length, 'ranges'); if (ranges.length > 0) { const range = ranges[0]; return { @@ -40,6 +43,8 @@ export class WysiwygSelection { } catch (error) { console.warn('getComposedRanges failed, falling back to getRangeAt:', error); } + } else { + console.log('getComposedRanges not available, using fallback'); } // Fallback to traditional selection API