/** * Utilities for handling selection across Shadow DOM boundaries */ export interface SelectionInfo { startContainer: Node; startOffset: number; endContainer: Node; endOffset: number; collapsed: boolean; } // Type for the extended caretPositionFromPoint with Shadow DOM support type CaretPositionFromPointExtended = (x: number, y: number, ...shadowRoots: ShadowRoot[]) => CaretPosition | null; 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(); 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 { 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); } } else { console.log('getComposedRanges not available, using fallback'); } // 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, ...shadowRoots: ShadowRoot[]): number | null { const selectionInfo = shadowRoots.length > 0 ? this.getSelectionInfo(...shadowRoots) : 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); // Handle case where selection is in a text node that's a child of the element if (element.contains(selectionInfo.startContainer)) { range.setEnd(selectionInfo.startContainer, selectionInfo.startOffset); return range.toString().length; } else { // Selection might be in shadow DOM or different context // Try to find the equivalent position in the element const text = element.textContent || ''; const selectionText = selectionInfo.startContainer.textContent || ''; // If the selection is at the beginning or end, handle those cases if (selectionInfo.startOffset === 0) { return 0; } else if (selectionInfo.startOffset === selectionText.length) { return text.length; } // For other cases, try to match based on text content console.warn('Selection container not within element, using text matching fallback'); return selectionInfo.startOffset; } } catch (error) { console.warn('Failed to get cursor position:', error); return null; } } /** * Gets cursor position from mouse coordinates with Shadow DOM support */ static getCursorPositionFromPoint(x: number, y: number, container: HTMLElement, ...shadowRoots: ShadowRoot[]): number | null { // Try modern API with shadow root support if ('caretPositionFromPoint' in document && document.caretPositionFromPoint) { let caretPos: CaretPosition | null = null; // Try with shadow roots first (newer API) try { caretPos = (document.caretPositionFromPoint as any)(x, y, ...shadowRoots); } catch (e) { // Fallback to standard API without shadow roots caretPos = document.caretPositionFromPoint(x, y); } if (caretPos && container.contains(caretPos.offsetNode)) { // Calculate total offset within the container return this.getOffsetInElement(caretPos.offsetNode, caretPos.offset, container); } } // Safari/WebKit fallback if ('caretRangeFromPoint' in document) { const range = (document as any).caretRangeFromPoint(x, y); if (range && container.contains(range.startContainer)) { return this.getOffsetInElement(range.startContainer, range.startOffset, container); } } return null; } /** * Helper to get the total character offset of a position within an element */ private static getOffsetInElement(node: Node, offset: number, container: HTMLElement): number { let totalOffset = 0; let found = false; const walker = document.createTreeWalker( container, NodeFilter.SHOW_TEXT, null ); let textNode: Node | null; while (textNode = walker.nextNode()) { if (textNode === node) { totalOffset += offset; found = true; break; } else { totalOffset += textNode.textContent?.length || 0; } } return found ? totalOffset : 0; } /** * 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); } } }