import { type IBlock } from './wysiwyg.types.js'; export class WysiwygKeyboardHandler { private component: any; constructor(component: any) { 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 '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; 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(); // Get the block component const target = e.target as HTMLElement; const blockWrapper = target.closest('.block-wrapper'); const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any; if (blockComponent && blockComponent.getSplitContent) { const splitContent = blockComponent.getSplitContent(); if (splitContent) { // Update current block with content before cursor blockComponent.setContent(splitContent.before); block.content = splitContent.before; // Create new block with content after cursor const newBlock = blockOps.createBlock('paragraph', splitContent.after); // Insert the new block await blockOps.insertBlockAfter(block, newBlock); // Update the value after both blocks are set this.component.updateValue(); } else { // Fallback - just create empty block const newBlock = blockOps.createBlock(); await blockOps.insertBlockAfter(block, newBlock); } } else { // No block component or method, just create 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 { if (block.content === '' && this.component.blocks.length > 1) { e.preventDefault(); const blockOps = this.component.blockOperations; const prevBlock = blockOps.getPreviousBlock(block.id); if (prevBlock) { blockOps.removeBlock(block.id); if (prevBlock.type !== 'divider') { await blockOps.focusBlock(prevBlock.id, 'end'); } } } } /** * Handles ArrowUp key - navigate to previous block if at beginning */ private async handleArrowUp(e: KeyboardEvent, block: IBlock): Promise { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return; const range = selection.getRangeAt(0); const target = e.target as HTMLElement; // Check if cursor is at the beginning of the block const isAtStart = range.startOffset === 0 && range.endOffset === 0; if (isAtStart) { const firstNode = target.firstChild; const isReallyAtStart = !firstNode || (range.startContainer === firstNode && range.startOffset === 0) || (range.startContainer === target && range.startOffset === 0); if (isReallyAtStart) { e.preventDefault(); const blockOps = this.component.blockOperations; const prevBlock = blockOps.getPreviousBlock(block.id); if (prevBlock && prevBlock.type !== 'divider') { await blockOps.focusBlock(prevBlock.id, 'end'); } } } } /** * Handles ArrowDown key - navigate to next block if at end */ private async handleArrowDown(e: KeyboardEvent, block: IBlock): Promise { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return; const range = selection.getRangeAt(0); const target = e.target as HTMLElement; // Check if cursor is at the end of the block const lastNode = target.lastChild; // For different block types, check if we're at the end let isAtEnd = false; if (!lastNode) { // Empty block isAtEnd = true; } else if (lastNode.nodeType === Node.TEXT_NODE) { isAtEnd = range.endContainer === lastNode && range.endOffset === lastNode.textContent?.length; } else if (block.type === 'list') { // For lists, check if we're in the last item at the end const lastLi = target.querySelector('li:last-child'); if (lastLi) { const lastTextNode = this.getLastTextNode(lastLi); isAtEnd = lastTextNode && range.endContainer === lastTextNode && range.endOffset === lastTextNode.textContent?.length; } } else { // For other HTML content const lastTextNode = this.getLastTextNode(target); isAtEnd = lastTextNode && range.endContainer === lastTextNode && range.endOffset === lastTextNode.textContent?.length; } if (isAtEnd) { e.preventDefault(); const blockOps = this.component.blockOperations; const nextBlock = blockOps.getNextBlock(block.id); if (nextBlock && nextBlock.type !== 'divider') { await blockOps.focusBlock(nextBlock.id, 'start'); } } } /** * 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 { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return; const range = selection.getRangeAt(0); // Check if cursor is at the very beginning (collapsed and at offset 0) if (range.collapsed && range.startOffset === 0) { const target = e.target as HTMLElement; const firstNode = target.firstChild; // Verify we're really at the start const isAtStart = !firstNode || (range.startContainer === firstNode) || (range.startContainer === target); if (isAtStart) { const blockOps = this.component.blockOperations; const prevBlock = blockOps.getPreviousBlock(block.id); if (prevBlock && prevBlock.type !== 'divider') { e.preventDefault(); await blockOps.focusBlock(prevBlock.id, '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 { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return; const range = selection.getRangeAt(0); const target = e.target as HTMLElement; // Check if cursor is at the very end if (range.collapsed) { const textLength = target.textContent?.length || 0; let isAtEnd = false; if (textLength === 0) { // Empty block isAtEnd = true; } else if (range.endContainer.nodeType === Node.TEXT_NODE) { const textNode = range.endContainer as Text; isAtEnd = range.endOffset === textNode.textContent?.length; } else { // Check if we're at the end of the last text node const lastTextNode = this.getLastTextNode(target); if (lastTextNode) { isAtEnd = range.endContainer === lastTextNode && range.endOffset === lastTextNode.textContent?.length; } } if (isAtEnd) { const blockOps = this.component.blockOperations; const nextBlock = blockOps.getNextBlock(block.id); if (nextBlock && nextBlock.type !== 'divider') { e.preventDefault(); await blockOps.focusBlock(nextBlock.id, 'start'); } } } // Otherwise, let the browser handle normal right arrow navigation } /** * Handles slash menu keyboard navigation * Note: This is now handled by the component directly */ }