diff --git a/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts b/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts index eba814f..80256da 100644 --- a/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts +++ b/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts @@ -6,7 +6,6 @@ import { type TemplateResult, cssManager, css, - query, } from '@design.estate/dees-element'; import { type IBlock } from './wysiwyg.types.js'; @@ -38,11 +37,14 @@ export class DeesWysiwygBlock extends DeesElement { onMouseUp?: (e: MouseEvent) => void; }; - @query('.block') - private blockElement: HTMLDivElement; + // Reference to the editable block element + private blockElement: HTMLDivElement | null = null; // Track if we've initialized the content private contentInitialized: boolean = false; + + // Track cursor position + private lastKnownCursorPosition: number = 0; public static styles = [ cssManager.defaultStyles, @@ -270,10 +272,16 @@ export class DeesWysiwygBlock extends DeesElement { // Mark that content has been initialized this.contentInitialized = true; - // For code blocks, the actual contenteditable block is nested + // First, populate the container with the rendered content + const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement; + if (container && this.block) { + container.innerHTML = this.renderBlockContent(); + } + + // Now find the actual editable block element const editableBlock = this.block.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement - : this.blockElement; + : this.shadowRoot?.querySelector('.block') as HTMLDivElement; // Ensure the block element maintains its content if (editableBlock) { @@ -282,16 +290,31 @@ export class DeesWysiwygBlock extends DeesElement { // Set up all event handlers manually to avoid Lit re-renders editableBlock.addEventListener('input', (e) => { - this.logCursorPosition('input'); this.handlers?.onInput?.(e as InputEvent); + + // Track cursor position after input + const pos = this.getCursorPosition(editableBlock); + if (pos !== null) { + this.lastKnownCursorPosition = pos; + } }); editableBlock.addEventListener('keydown', (e) => { + // Track cursor position before keydown + const pos = this.getCursorPosition(editableBlock); + if (pos !== null) { + this.lastKnownCursorPosition = pos; + } + this.handlers?.onKeyDown?.(e); }); editableBlock.addEventListener('keyup', (e) => { - this.logCursorPosition('keyup', e); + // Track cursor position after key release + const pos = this.getCursorPosition(editableBlock); + if (pos !== null) { + this.lastKnownCursorPosition = pos; + } }); editableBlock.addEventListener('focus', () => { @@ -311,13 +334,28 @@ export class DeesWysiwygBlock extends DeesElement { }); editableBlock.addEventListener('mouseup', (e) => { - this.logCursorPosition('mouseup'); + // Small delay to let browser set cursor position + setTimeout(() => { + const pos = this.getCursorPosition(editableBlock); + if (pos !== null) { + this.lastKnownCursorPosition = pos; + console.log('Cursor position after mouseup:', pos); + } + }, 0); + this.handleMouseUp(e); this.handlers?.onMouseUp?.(e); }); - editableBlock.addEventListener('click', () => { - this.logCursorPosition('click'); + editableBlock.addEventListener('click', (e: MouseEvent) => { + // Small delay to let browser set cursor position + setTimeout(() => { + const pos = this.getCursorPosition(editableBlock); + if (pos !== null) { + this.lastKnownCursorPosition = pos; + console.log('Cursor position after click:', pos); + } + }, 0); }); // Set initial content if needed @@ -332,15 +370,23 @@ export class DeesWysiwygBlock extends DeesElement { } } - // For code blocks, we use the nested editableBlock - // The blockElement getter will automatically find the right element + // Store reference to the block element for quick access + this.blockElement = editableBlock; } render(): TemplateResult { if (!this.block) return html``; + // Since we need dynamic content, we'll render an empty container + // and set the innerHTML in firstUpdated + return html`
`; + } + + private renderBlockContent(): string { + if (!this.block) return ''; + if (this.block.type === 'divider') { - return html` + return `

@@ -349,11 +395,12 @@ export class DeesWysiwygBlock extends DeesElement { if (this.block.type === 'code') { const language = this.block.metadata?.language || 'plain text'; - return html` + const selectedClass = this.isSelected ? ' selected' : ''; + return `
${language}
@@ -362,11 +409,10 @@ export class DeesWysiwygBlock extends DeesElement { } const placeholder = this.getPlaceholder(); - - // Return static HTML without event bindings - return html` + const selectedClass = this.isSelected ? ' selected' : ''; + return `
@@ -395,7 +441,7 @@ export class DeesWysiwygBlock extends DeesElement { // Get the actual editable element (might be nested for code blocks) const editableElement = this.block?.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement - : this.blockElement; + : this.shadowRoot?.querySelector('.block') as HTMLDivElement; if (!editableElement) return; @@ -418,7 +464,7 @@ export class DeesWysiwygBlock extends DeesElement { // Get the actual editable element (might be nested for code blocks) const editableElement = this.block?.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement - : this.blockElement; + : this.shadowRoot?.querySelector('.block') as HTMLDivElement; if (!editableElement) return; @@ -455,24 +501,66 @@ export class DeesWysiwygBlock extends DeesElement { } } - private getFirstTextNode(node: Node): Text | null { - if (node.nodeType === Node.TEXT_NODE) { - return node as Text; + + + /** + * Get cursor position in the editable element + */ + private getCursorPosition(element: HTMLElement): number | null { + // Get parent wysiwyg component's shadow root + const parentComponent = this.closest('dees-input-wysiwyg'); + const parentShadowRoot = parentComponent?.shadowRoot; + + // Get selection info with both shadow roots for proper traversal + const shadowRoots: ShadowRoot[] = []; + if (parentShadowRoot) shadowRoots.push(parentShadowRoot); + if (this.shadowRoot) shadowRoots.push(this.shadowRoot); + + const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots); + console.log('getCursorPosition: Selection info from shadow DOMs:', { + selectionInfo, + shadowRootsCount: shadowRoots.length + }); + + if (!selectionInfo) { + console.log('getCursorPosition: No selection found'); + return null; } - for (let i = 0; i < node.childNodes.length; i++) { - const textNode = this.getFirstTextNode(node.childNodes[i]); - if (textNode) return textNode; + console.log('getCursorPosition: Range info:', { + startContainer: selectionInfo.startContainer, + startOffset: selectionInfo.startOffset, + collapsed: selectionInfo.collapsed, + startContainerText: selectionInfo.startContainer.textContent + }); + + if (!element.contains(selectionInfo.startContainer)) { + console.log('getCursorPosition: Range not in element'); + return null; } - return null; + // Create a range from start of element to cursor position + const preCaretRange = document.createRange(); + preCaretRange.selectNodeContents(element); + preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset); + + // Get the text content length up to cursor + const position = preCaretRange.toString().length; + console.log('getCursorPosition: Calculated position:', { + position, + preCaretText: preCaretRange.toString(), + elementText: element.textContent, + elementTextLength: element.textContent?.length + }); + + return position; } public getContent(): string { // Get the actual editable element (might be nested for code blocks) const editableElement = this.block?.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement - : this.blockElement; + : this.shadowRoot?.querySelector('.block') as HTMLDivElement; if (!editableElement) return ''; @@ -490,7 +578,7 @@ export class DeesWysiwygBlock extends DeesElement { // Get the actual editable element (might be nested for code blocks) const editableElement = this.block?.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement - : this.blockElement; + : this.shadowRoot?.querySelector('.block') as HTMLDivElement; if (!editableElement) return; @@ -531,7 +619,10 @@ export class DeesWysiwygBlock extends DeesElement { public focusListItem(): void { if (this.block.type === 'list') { - WysiwygBlocks.focusListItem(this.blockElement); + const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement; + if (editableElement) { + WysiwygBlocks.focusListItem(editableElement); + } } } @@ -539,179 +630,149 @@ export class DeesWysiwygBlock extends DeesElement { * Gets content split at cursor position */ public getSplitContent(): { before: string; after: string } | null { - if (!this.blockElement) return null; + console.log('getSplitContent: Starting...'); - // Get the full content first - const fullContent = this.getContent(); - console.log('getSplitContent: Full content:', { - content: fullContent, - length: fullContent.length, - blockType: this.block.type - }); - - // Direct approach: Get selection from window - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) { - console.log('getSplitContent: No selection found'); - return { - before: fullContent, - after: '' - }; - } - - const range = selection.getRangeAt(0); - console.log('getSplitContent: Range info:', { - startContainer: range.startContainer, - startOffset: range.startOffset, - collapsed: range.collapsed, - startContainerType: range.startContainer.nodeType, - startContainerText: range.startContainer.textContent?.substring(0, 50) - }); - - // Check if this block element has focus or contains the selection - const activeElement = this.shadowRoot?.activeElement || document.activeElement; - const hasFocus = this.blockElement === activeElement || this.blockElement?.contains(activeElement as Node); - - // For contenteditable, check if selection is in our shadow DOM - let selectionInThisBlock = false; - try { - // Walk up from the selection to see if we reach our block element - let node: Node | null = range.startContainer; - while (node) { - if (node === this.blockElement || node === this.shadowRoot) { - selectionInThisBlock = true; - break; - } - node = node.parentNode || (node as any).host; // Check shadow host too - } - } catch (e) { - console.log('Error checking selection ancestry:', e); - } - - console.log('getSplitContent: Focus check:', { - hasFocus, - selectionInThisBlock, - activeElement, - blockElement: this.blockElement - }); - - if (!hasFocus && !selectionInThisBlock) { - console.log('getSplitContent: Block does not have focus/selection'); - return null; - } - - // Get the actual editable element (might be nested for code blocks) - const editableElement = this.block.type === 'code' + // Get the actual editable element first + const editableElement = this.block?.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement - : this.blockElement; + : this.shadowRoot?.querySelector('.block') as HTMLDivElement; if (!editableElement) { console.log('getSplitContent: No editable element found'); return null; } - // Handle special cases for different block types - if (this.block.type === 'code') { - // For code blocks, split text content - const fullText = editableElement.textContent || ''; - const textNode = this.getFirstTextNode(editableElement); - - if (textNode && range.startContainer === textNode) { - const before = fullText.substring(0, range.startOffset); - const after = fullText.substring(range.startOffset); - - console.log('getSplitContent: Code block split result:', { - contentLength: fullText.length, - beforeContent: before, - beforeLength: before.length, - afterContent: after, - afterLength: after.length, - startOffset: range.startOffset - }); - - return { before, after }; - } - } + console.log('getSplitContent: Element info:', { + blockType: this.block.type, + innerHTML: editableElement.innerHTML, + textContent: editableElement.textContent, + textLength: editableElement.textContent?.length + }); - // For other block types, extract HTML content - try { - // If selection is not directly in our element, try to find cursor position by text - if (!editableElement.contains(range.startContainer)) { - // Simple approach: split at cursor position in text - const textContent = editableElement.textContent || ''; - const cursorPos = range.startOffset; // Simplified cursor position - - const beforeText = textContent.substring(0, cursorPos); - const afterText = textContent.substring(cursorPos); - - console.log('Splitting by text position (fallback):', { - cursorPos, - beforeText, - afterText, - totalLength: textContent.length + // Get parent wysiwyg component's shadow root + const parentComponent = this.closest('dees-input-wysiwyg'); + const parentShadowRoot = parentComponent?.shadowRoot; + + // Get selection info with both shadow roots for proper traversal + const shadowRoots: ShadowRoot[] = []; + if (parentShadowRoot) shadowRoots.push(parentShadowRoot); + if (this.shadowRoot) shadowRoots.push(this.shadowRoot); + + const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots); + console.log('getSplitContent: Selection info from shadow DOMs:', { + selectionInfo, + shadowRootsCount: shadowRoots.length + }); + + if (!selectionInfo) { + console.log('getSplitContent: No selection, using last known position:', this.lastKnownCursorPosition); + // Try using last known cursor position + if (this.lastKnownCursorPosition !== null) { + const fullText = editableElement.textContent || ''; + const pos = Math.min(this.lastKnownCursorPosition, fullText.length); + console.log('getSplitContent: Splitting with last known position:', { + pos, + fullTextLength: fullText.length, + before: fullText.substring(0, pos), + after: fullText.substring(pos) }); - - // For now, return text-based split return { - before: beforeText, - after: afterText + before: fullText.substring(0, pos), + after: fullText.substring(pos) }; } - - // Create a temporary range to get content before cursor - const beforeRange = document.createRange(); - beforeRange.selectNodeContents(editableElement); - beforeRange.setEnd(range.startContainer, range.startOffset); - - // Create a temporary range to get content after cursor - const afterRange = document.createRange(); - afterRange.selectNodeContents(editableElement); - afterRange.setStart(range.startContainer, range.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:', { - 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; + return null; } + + console.log('getSplitContent: Selection range:', { + startContainer: selectionInfo.startContainer, + startOffset: selectionInfo.startOffset, + startContainerInElement: editableElement.contains(selectionInfo.startContainer) + }); + + // Make sure the selection is within this block + if (!editableElement.contains(selectionInfo.startContainer)) { + console.log('getSplitContent: Selection not in this block, using last known position:', this.lastKnownCursorPosition); + // Try using last known cursor position + if (this.lastKnownCursorPosition !== null) { + const fullText = editableElement.textContent || ''; + const pos = Math.min(this.lastKnownCursorPosition, fullText.length); + return { + before: fullText.substring(0, pos), + after: fullText.substring(pos) + }; + } + return null; + } + + // For code blocks, use simple text splitting + if (this.block.type === 'code') { + const cursorPos = this.getCursorPosition(editableElement) || 0; + const fullText = editableElement.textContent || ''; + + console.log('getSplitContent: Code block split:', { + cursorPos, + fullTextLength: fullText.length, + before: fullText.substring(0, cursorPos), + after: fullText.substring(cursorPos) + }); + + return { + before: fullText.substring(0, cursorPos), + after: fullText.substring(cursorPos) + }; + } + + // For HTML content, get cursor position first + const cursorPos = this.getCursorPosition(editableElement); + console.log('getSplitContent: Cursor position for HTML split:', cursorPos); + + if (cursorPos === null || cursorPos === 0) { + // If cursor is at start or can't determine position, move all content + console.log('getSplitContent: Cursor at start or null, moving all content'); + return { + before: '', + after: editableElement.innerHTML + }; + } + + // For HTML content, split using ranges to preserve formatting + const beforeRange = document.createRange(); + const afterRange = document.createRange(); + + // Before range: from start of element to cursor + beforeRange.setStart(editableElement, 0); + beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset); + + // After range: from cursor to end of element + afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset); + afterRange.setEnd(editableElement, editableElement.childNodes.length); + + // Extract HTML content + const beforeFragment = beforeRange.cloneContents(); + const afterFragment = afterRange.cloneContents(); + + // Convert to HTML strings + const tempDiv = document.createElement('div'); + tempDiv.appendChild(beforeFragment); + const beforeHtml = tempDiv.innerHTML; + + tempDiv.innerHTML = ''; + tempDiv.appendChild(afterFragment); + const afterHtml = tempDiv.innerHTML; + + console.log('getSplitContent: Final split result:', { + cursorPos, + beforeHtml, + beforeLength: beforeHtml.length, + afterHtml, + afterLength: afterHtml.length + }); + + return { + before: beforeHtml, + after: afterHtml + }; } private handleMouseUp(_e: MouseEvent): void { @@ -722,7 +783,10 @@ export class DeesWysiwygBlock extends DeesElement { const range = selection.getRangeAt(0); // Check if selection is within this block - if (this.blockElement && this.blockElement.contains(range.commonAncestorContainer)) { + 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 @@ -740,117 +804,4 @@ 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/instructions.md b/ts_web/elements/wysiwyg/instructions.md index f6240d8..6c7a07e 100644 --- a/ts_web/elements/wysiwyg/instructions.md +++ b/ts_web/elements/wysiwyg/instructions.md @@ -2,4 +2,6 @@ * 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. -* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges \ No newline at end of file +* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges +* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges +* Make sure to hand over correct shodowroots. diff --git a/ts_web/elements/wysiwyg/wysiwyg.selection.ts b/ts_web/elements/wysiwyg/wysiwyg.selection.ts index 41a247c..414ebc7 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.selection.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.selection.ts @@ -10,6 +10,9 @@ export interface SelectionInfo { 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 @@ -22,7 +25,8 @@ export class WysiwygSelection { // Try using getComposedRanges if available (better Shadow DOM support) if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function') { try { - const ranges = selection.getComposedRanges(...shadowRoots); + // Pass shadow roots in the correct format as per MDN + const ranges = selection.getComposedRanges({ shadowRoots }); if (ranges.length > 0) { const range = ranges[0]; return { @@ -139,6 +143,66 @@ export class WysiwygSelection { } } + /** + * 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 */