import { type IBlock } from './wysiwyg.types.js'; import { type IWysiwygComponent } from './wysiwyg.interfaces.js'; import { WysiwygSelection } from './wysiwyg.selection.js'; export class WysiwygKeyboardHandler { private component: IWysiwygComponent; constructor(component: IWysiwygComponent) { this.component = component; } /** * Handles keyboard events for blocks */ async handleBlockKeyDown(e: KeyboardEvent, block: IBlock): Promise { // Handle slash menu navigation if (this.component.slashMenu.visible && this.isSlashMenuKey(e.key)) { this.component.handleSlashMenuKeyboard(e); return; } // Handle formatting shortcuts if (this.handleFormattingShortcuts(e)) { return; } // Handle special keys switch (e.key) { case 'Tab': this.handleTab(e, block); break; case 'Enter': await this.handleEnter(e, block); break; case 'Backspace': await this.handleBackspace(e, block); break; case 'Delete': await this.handleDelete(e, block); break; case 'ArrowUp': await this.handleArrowUp(e, block); break; case 'ArrowDown': await this.handleArrowDown(e, block); break; case 'ArrowLeft': await this.handleArrowLeft(e, block); break; case 'ArrowRight': await this.handleArrowRight(e, block); break; } } /** * Checks if key is for slash menu navigation */ private isSlashMenuKey(key: string): boolean { return ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(key); } /** * Handles formatting keyboard shortcuts */ private handleFormattingShortcuts(e: KeyboardEvent): boolean { if (!(e.metaKey || e.ctrlKey)) return false; switch (e.key.toLowerCase()) { case 'b': e.preventDefault(); // Use Promise to ensure focus is maintained Promise.resolve().then(() => this.component.applyFormat('bold')); return true; case 'i': e.preventDefault(); Promise.resolve().then(() => this.component.applyFormat('italic')); return true; case 'u': e.preventDefault(); Promise.resolve().then(() => this.component.applyFormat('underline')); return true; case 'k': e.preventDefault(); Promise.resolve().then(() => this.component.applyFormat('link')); return true; } return false; } /** * Handles Tab key */ private handleTab(e: KeyboardEvent, block: IBlock): void { if (block.type === 'code') { // Allow tab in code blocks e.preventDefault(); // Insert two spaces for tab const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); range.deleteContents(); const textNode = document.createTextNode(' '); range.insertNode(textNode); range.setStartAfter(textNode); range.setEndAfter(textNode); selection.removeAllRanges(); selection.addRange(range); } } else if (block.type === 'list') { // Future: implement list indentation e.preventDefault(); } } /** * Handles Enter key */ private async handleEnter(e: KeyboardEvent, block: IBlock): Promise { const blockOps = this.component.blockOperations; // For non-editable blocks, create a new paragraph after if (block.type === 'divider' || block.type === 'image') { e.preventDefault(); const newBlock = blockOps.createBlock(); await blockOps.insertBlockAfter(block, newBlock); return; } if (block.type === 'code') { if (e.shiftKey) { // Shift+Enter in code blocks creates a new block e.preventDefault(); const newBlock = blockOps.createBlock(); await blockOps.insertBlockAfter(block, newBlock); } // Normal Enter in code blocks creates new line (let browser handle it) return; } if (!e.shiftKey) { if (block.type === 'list') { await this.handleEnterInList(e, block); } else { // Split content at cursor position e.preventDefault(); 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); } } } // Shift+Enter creates line break (let browser handle it) } /** * Handles Enter key in list blocks */ private async handleEnterInList(e: KeyboardEvent, block: IBlock): Promise { const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); const currentLi = range.startContainer.parentElement?.closest('li'); if (currentLi && currentLi.textContent === '') { // Empty list item - exit list mode e.preventDefault(); const blockOps = this.component.blockOperations; const newBlock = blockOps.createBlock(); await blockOps.insertBlockAfter(block, newBlock); } // Otherwise, let browser create new list item } } /** * Handles Backspace key */ private async handleBackspace(e: KeyboardEvent, block: IBlock): Promise { const blockOps = this.component.blockOperations; // Handle non-editable blocks const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; if (nonEditableTypes.includes(block.type)) { e.preventDefault(); // If it's the only block, delete it and create a new paragraph if (this.component.blocks.length === 1) { // Save state for undo this.component.saveToHistory(false); // Remove the block blockOps.removeBlock(block.id); // Create a new paragraph block const newBlock = blockOps.createBlock('paragraph', ''); this.component.blocks = [newBlock]; // Re-render blocks this.component.renderBlocksProgrammatically(); // Focus the new block await blockOps.focusBlock(newBlock.id, 'start'); // Update value this.component.updateValue(); return; } // Save state for undo this.component.saveToHistory(false); // Find the previous block to focus const prevBlock = blockOps.getPreviousBlock(block.id); const nextBlock = blockOps.getNextBlock(block.id); // Remove the block blockOps.removeBlock(block.id); // Focus the appropriate block if (prevBlock && prevBlock.type !== 'divider' && prevBlock.type !== 'image') { await blockOps.focusBlock(prevBlock.id, 'end'); } else if (nextBlock && nextBlock.type !== 'divider' && nextBlock.type !== 'image') { await blockOps.focusBlock(nextBlock.id, 'start'); } else if (prevBlock) { // If previous block is also non-editable, just select it await blockOps.focusBlock(prevBlock.id); } else if (nextBlock) { // If next block is also non-editable, just select it await blockOps.focusBlock(nextBlock.id); } return; } // Get the block component to check cursor position const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`); const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any; if (!blockComponent || !blockComponent.shadowRoot) return; // Get the actual editable element const target = block.type === 'code' ? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement : blockComponent.shadowRoot.querySelector('.block') as HTMLElement; if (!target) return; // Get cursor position const parentComponent = blockComponent.closest('dees-input-wysiwyg'); const shadowRoots: ShadowRoot[] = []; if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot); shadowRoots.push(blockComponent.shadowRoot); const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots); // Check if cursor is at the beginning of the block if (cursorPos === 0) { e.preventDefault(); const prevBlock = blockOps.getPreviousBlock(block.id); if (prevBlock) { // If previous block is non-editable, select it first const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; if (nonEditableTypes.includes(prevBlock.type)) { await blockOps.focusBlock(prevBlock.id); return; } // Save checkpoint for undo this.component.saveToHistory(false); // Special handling for different block types if (prevBlock.type === 'code' && block.type !== 'code') { // Can't merge non-code into code block, just remove empty block if (block.content === '') { blockOps.removeBlock(block.id); await blockOps.focusBlock(prevBlock.id, 'end'); } return; } if (block.type === 'code' && prevBlock.type !== 'code') { // Can't merge code into non-code block if (block.content === '') { blockOps.removeBlock(block.id); await blockOps.focusBlock(prevBlock.id, 'end'); } return; } // Get the content of both blocks const prevBlockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${prevBlock.id}"]`); const prevBlockComponent = prevBlockWrapper?.querySelector('dees-wysiwyg-block') as any; const prevContent = prevBlockComponent?.getContent() || prevBlock.content || ''; const currentContent = blockComponent.getContent() || block.content || ''; // Merge content let mergedContent = ''; if (prevBlock.type === 'code' && block.type === 'code') { // For code blocks, join with newline mergedContent = prevContent + (prevContent && currentContent ? '\n' : '') + currentContent; } else if (prevBlock.type === 'list' && block.type === 'list') { // For lists, combine the list items mergedContent = prevContent + (prevContent && currentContent ? '\n' : '') + currentContent; } else { // For other blocks, join with space if both have content mergedContent = prevContent + (prevContent && currentContent ? ' ' : '') + currentContent; } // Store cursor position (where the merge point is) const mergePoint = prevContent.length; // Update previous block with merged content blockOps.updateBlockContent(prevBlock.id, mergedContent); if (prevBlockComponent) { prevBlockComponent.setContent(mergedContent); } // Remove current block blockOps.removeBlock(block.id); // Focus previous block at merge point await blockOps.focusBlock(prevBlock.id, mergePoint); } } else if (block.content === '' && this.component.blocks.length > 1) { // Empty block - just remove it e.preventDefault(); const prevBlock = blockOps.getPreviousBlock(block.id); if (prevBlock) { blockOps.removeBlock(block.id); if (prevBlock.type !== 'divider') { await blockOps.focusBlock(prevBlock.id, 'end'); } } } // Otherwise, let browser handle normal backspace } /** * Handles Delete key */ private async handleDelete(e: KeyboardEvent, block: IBlock): Promise { const blockOps = this.component.blockOperations; // Handle non-editable blocks - same as backspace const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; if (nonEditableTypes.includes(block.type)) { e.preventDefault(); // If it's the only block, delete it and create a new paragraph if (this.component.blocks.length === 1) { // Save state for undo this.component.saveToHistory(false); // Remove the block blockOps.removeBlock(block.id); // Create a new paragraph block const newBlock = blockOps.createBlock('paragraph', ''); this.component.blocks = [newBlock]; // Re-render blocks this.component.renderBlocksProgrammatically(); // Focus the new block await blockOps.focusBlock(newBlock.id, 'start'); // Update value this.component.updateValue(); return; } // Save state for undo this.component.saveToHistory(false); // Find the previous block to focus const prevBlock = blockOps.getPreviousBlock(block.id); const nextBlock = blockOps.getNextBlock(block.id); // Remove the block blockOps.removeBlock(block.id); // Focus the appropriate block const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; if (nextBlock && !nonEditableTypes.includes(nextBlock.type)) { await blockOps.focusBlock(nextBlock.id, 'start'); } else if (prevBlock && !nonEditableTypes.includes(prevBlock.type)) { await blockOps.focusBlock(prevBlock.id, 'end'); } else if (nextBlock) { // If next block is also non-editable, just select it await blockOps.focusBlock(nextBlock.id); } else if (prevBlock) { // If previous block is also non-editable, just select it await blockOps.focusBlock(prevBlock.id); } return; } // For editable blocks, check if we're at the end and next block is non-editable const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`); const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any; if (!blockComponent || !blockComponent.shadowRoot) return; // Get the actual editable element const target = block.type === 'code' ? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement : blockComponent.shadowRoot.querySelector('.block') as HTMLElement; if (!target) return; // Get cursor position const parentComponent = blockComponent.closest('dees-input-wysiwyg'); const shadowRoots: ShadowRoot[] = []; if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot); shadowRoots.push(blockComponent.shadowRoot); const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots); const textLength = target.textContent?.length || 0; // Check if cursor is at the end of the block if (cursorPos === textLength) { const nextBlock = blockOps.getNextBlock(block.id); const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; if (nextBlock && nonEditableTypes.includes(nextBlock.type)) { e.preventDefault(); await blockOps.focusBlock(nextBlock.id); return; } } // Otherwise, let browser handle normal delete } /** * Handles ArrowUp key - navigate to previous block if at beginning or first line */ private async handleArrowUp(e: KeyboardEvent, block: IBlock): Promise { // For non-editable blocks, always navigate to previous block const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; if (nonEditableTypes.includes(block.type)) { e.preventDefault(); const blockOps = this.component.blockOperations; const prevBlock = blockOps.getPreviousBlock(block.id); if (prevBlock) { await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end'); } return; } // Get the block component from the wysiwyg component's shadow DOM const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`); const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block'); if (!blockComponent || !blockComponent.shadowRoot) return; // Get the actual editable element (code blocks have .block.code) const target = block.type === 'code' ? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement : blockComponent.shadowRoot.querySelector('.block') as HTMLElement; if (!target) return; // Get selection info with proper shadow DOM support const parentComponent = blockComponent.closest('dees-input-wysiwyg'); const shadowRoots: ShadowRoot[] = []; if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot); shadowRoots.push(blockComponent.shadowRoot); const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots); if (!selectionInfo || !selectionInfo.collapsed) return; // Check if we're on the first line if (this.isOnFirstLine(selectionInfo, target, ...shadowRoots)) { e.preventDefault(); const blockOps = this.component.blockOperations; const prevBlock = blockOps.getPreviousBlock(block.id); if (prevBlock) { const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end'); } } // Otherwise, let browser handle normal navigation } /** * Handles ArrowDown key - navigate to next block if at end or last line */ private async handleArrowDown(e: KeyboardEvent, block: IBlock): Promise { // For non-editable blocks, always navigate to next block const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; if (nonEditableTypes.includes(block.type)) { e.preventDefault(); const blockOps = this.component.blockOperations; const nextBlock = blockOps.getNextBlock(block.id); if (nextBlock) { const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start'); } return; } // Get the block component from the wysiwyg component's shadow DOM const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`); const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block'); if (!blockComponent || !blockComponent.shadowRoot) return; // Get the actual editable element (code blocks have .block.code) const target = block.type === 'code' ? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement : blockComponent.shadowRoot.querySelector('.block') as HTMLElement; if (!target) return; // Get selection info with proper shadow DOM support const parentComponent = blockComponent.closest('dees-input-wysiwyg'); const shadowRoots: ShadowRoot[] = []; if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot); shadowRoots.push(blockComponent.shadowRoot); const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots); if (!selectionInfo || !selectionInfo.collapsed) return; // Check if we're on the last line if (this.isOnLastLine(selectionInfo, target, ...shadowRoots)) { e.preventDefault(); const blockOps = this.component.blockOperations; const nextBlock = blockOps.getNextBlock(block.id); if (nextBlock) { const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start'); } } // Otherwise, let browser handle normal navigation } /** * Helper to get the last text node in an element */ private getLastTextNode(element: Node): Text | null { if (element.nodeType === Node.TEXT_NODE) { return element as Text; } for (let i = element.childNodes.length - 1; i >= 0; i--) { const lastText = this.getLastTextNode(element.childNodes[i]); if (lastText) return lastText; } return null; } /** * Handles ArrowLeft key - navigate to previous block if at beginning */ private async handleArrowLeft(e: KeyboardEvent, block: IBlock): Promise { // For non-editable blocks, navigate to previous block const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; if (nonEditableTypes.includes(block.type)) { e.preventDefault(); const blockOps = this.component.blockOperations; const prevBlock = blockOps.getPreviousBlock(block.id); if (prevBlock) { const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end'); } return; } // Get the block component from the wysiwyg component's shadow DOM const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`); const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block'); if (!blockComponent || !blockComponent.shadowRoot) return; // Get the actual editable element (code blocks have .block.code) const target = block.type === 'code' ? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement : blockComponent.shadowRoot.querySelector('.block') as HTMLElement; if (!target) return; // Get selection info with proper shadow DOM support const parentComponent = blockComponent.closest('dees-input-wysiwyg'); const shadowRoots: ShadowRoot[] = []; if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot); shadowRoots.push(blockComponent.shadowRoot); const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots); if (!selectionInfo || !selectionInfo.collapsed) return; // Check if cursor is at the beginning of the block const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots); if (cursorPos === 0) { const blockOps = this.component.blockOperations; const prevBlock = blockOps.getPreviousBlock(block.id); if (prevBlock) { e.preventDefault(); const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end'); } } // Otherwise, let the browser handle normal left arrow navigation } /** * Handles ArrowRight key - navigate to next block if at end */ private async handleArrowRight(e: KeyboardEvent, block: IBlock): Promise { // For non-editable blocks, navigate to next block const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; if (nonEditableTypes.includes(block.type)) { e.preventDefault(); const blockOps = this.component.blockOperations; const nextBlock = blockOps.getNextBlock(block.id); if (nextBlock) { const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start'); } return; } // Get the block component from the wysiwyg component's shadow DOM const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`); const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block'); if (!blockComponent || !blockComponent.shadowRoot) return; // Get the actual editable element (code blocks have .block.code) const target = block.type === 'code' ? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement : blockComponent.shadowRoot.querySelector('.block') as HTMLElement; if (!target) return; // Get selection info with proper shadow DOM support const parentComponent = blockComponent.closest('dees-input-wysiwyg'); const shadowRoots: ShadowRoot[] = []; if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot); shadowRoots.push(blockComponent.shadowRoot); const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots); if (!selectionInfo || !selectionInfo.collapsed) return; // Check if cursor is at the end of the block const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots); const textLength = target.textContent?.length || 0; if (cursorPos === textLength) { const blockOps = this.component.blockOperations; const nextBlock = blockOps.getNextBlock(block.id); if (nextBlock) { e.preventDefault(); const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment']; await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start'); } } // Otherwise, let the browser handle normal right arrow navigation } /** * Handles slash menu keyboard navigation * Note: This is now handled by the component directly */ /** * Check if cursor is on the first line of a block */ private isOnFirstLine(selectionInfo: any, target: HTMLElement, ...shadowRoots: ShadowRoot[]): boolean { try { // Create a range from the selection info const range = WysiwygSelection.createRangeFromInfo(selectionInfo); const rect = range.getBoundingClientRect(); // Get the container element let container = range.commonAncestorContainer; if (container.nodeType === Node.TEXT_NODE) { container = container.parentElement; } // Get the top position of the container const containerRect = (container as Element).getBoundingClientRect(); // Check if we're near the top (within 5px tolerance for line height variations) const isNearTop = rect.top - containerRect.top < 5; // For single-line content, also check if we're at the beginning if (container.textContent && !container.textContent.includes('\n')) { const cursorPos = WysiwygSelection.getCursorPositionInElement(container as Element, ...shadowRoots); return cursorPos === 0; } return isNearTop; } catch (e) { console.warn('Error checking first line:', e); // Fallback to position-based check const cursorPos = selectionInfo.startOffset; return cursorPos === 0; } } /** * Check if cursor is on the last line of a block */ private isOnLastLine(selectionInfo: any, target: HTMLElement, ...shadowRoots: ShadowRoot[]): boolean { try { // Create a range from the selection info const range = WysiwygSelection.createRangeFromInfo(selectionInfo); const rect = range.getBoundingClientRect(); // Get the container element let container = range.commonAncestorContainer; if (container.nodeType === Node.TEXT_NODE) { container = container.parentElement; } // Get the bottom position of the container const containerRect = (container as Element).getBoundingClientRect(); // Check if we're near the bottom (within 5px tolerance for line height variations) const isNearBottom = containerRect.bottom - rect.bottom < 5; // For single-line content, also check if we're at the end if (container.textContent && !container.textContent.includes('\n')) { const textLength = target.textContent?.length || 0; const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots); return cursorPos === textLength; } return isNearBottom; } catch (e) { console.warn('Error checking last line:', e); // Fallback to position-based check const textLength = target.textContent?.length || 0; const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots); return cursorPos === textLength; } } }