From ca525ce7e3de41370213f40583fd65424f6d8a7d Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 24 Jun 2025 15:52:28 +0000 Subject: [PATCH] feat(wysiwyg): implement backspace --- ts_web/elements/wysiwyg/dees-input-wysiwyg.ts | 111 +++++ ts_web/elements/wysiwyg/dees-wysiwyg-block.ts | 6 +- ts_web/elements/wysiwyg/index.ts | 1 + .../wysiwyg/wysiwyg.blockoperations.ts | 6 + ts_web/elements/wysiwyg/wysiwyg.history.ts | 167 ++++++++ ts_web/elements/wysiwyg/wysiwyg.interfaces.ts | 1 + .../wysiwyg/wysiwyg.keyboardhandler.ts | 379 +++++++++++++----- 7 files changed, 563 insertions(+), 108 deletions(-) create mode 100644 ts_web/elements/wysiwyg/wysiwyg.history.ts diff --git a/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts index 7664f87..18ccb91 100644 --- a/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts +++ b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts @@ -23,6 +23,7 @@ import { WysiwygKeyboardHandler, WysiwygDragDropHandler, WysiwygModalManager, + WysiwygHistory, DeesSlashMenu, DeesFormattingMenu } from './index.js'; @@ -89,6 +90,7 @@ export class DeesInputWysiwyg extends DeesInputBase { private inputHandler: WysiwygInputHandler; private keyboardHandler: WysiwygKeyboardHandler; private dragDropHandler: WysiwygDragDropHandler; + private history: WysiwygHistory; public static styles = [ ...DeesInputBase.baseStyles, @@ -103,6 +105,7 @@ export class DeesInputWysiwyg extends DeesInputBase { this.inputHandler = new WysiwygInputHandler(this); this.keyboardHandler = new WysiwygKeyboardHandler(this); this.dragDropHandler = new WysiwygDragDropHandler(this); + this.history = new WysiwygHistory(); } async connectedCallback() { @@ -143,6 +146,27 @@ export class DeesInputWysiwyg extends DeesInputBase { } }); + // Add global keyboard listener for undo/redo + this.addEventListener('keydown', (e: KeyboardEvent) => { + // Check if the event is from within our editor + const target = e.target as HTMLElement; + if (!this.contains(target) && !this.shadowRoot?.contains(target)) { + return; + } + + // Handle undo/redo + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === 'z') { + e.preventDefault(); + this.undo(); + } else if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'z') { + e.preventDefault(); + this.redo(); + } + }); + + // Save initial state to history + this.history.saveState(this.blocks, this.selectedBlockId); + // Render blocks programmatically this.renderBlocksProgrammatically(); } @@ -516,6 +540,9 @@ export class DeesInputWysiwyg extends DeesInputBase { this.value = WysiwygConverters.getMarkdownOutput(this.blocks); } this.changeSubject.next(this.value); + + // Save to history (debounced) + this.saveToHistory(true); } public getValue(): string { @@ -879,4 +906,88 @@ export class DeesInputWysiwyg extends DeesInputBase { }, 100); }); } + + /** + * Undo the last action + */ + private undo(): void { + console.log('Undo triggered'); + const state = this.history.undo(); + if (state) { + this.restoreState(state); + } + } + + /** + * Redo the next action + */ + private redo(): void { + console.log('Redo triggered'); + const state = this.history.redo(); + if (state) { + this.restoreState(state); + } + } + + /** + * Restore editor state from history + */ + private restoreState(state: { blocks: IBlock[]; selectedBlockId: string | null; cursorPosition?: { blockId: string; offset: number } }): void { + // Update blocks + this.blocks = state.blocks; + this.selectedBlockId = state.selectedBlockId; + + // Re-render blocks + this.renderBlocksProgrammatically(); + + // Restore cursor position if available + if (state.cursorPosition) { + setTimeout(() => { + const blockWrapper = this.shadowRoot?.querySelector(`[data-block-id="${state.cursorPosition!.blockId}"]`); + const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any; + if (blockComponent) { + blockComponent.focusWithCursor(state.cursorPosition!.offset); + } + }, 50); + } else if (state.selectedBlockId) { + // Just focus the selected block + setTimeout(() => { + this.blockOperations.focusBlock(state.selectedBlockId!); + }, 50); + } + + // Update value + this.updateValue(); + } + + /** + * Save current state to history with cursor position + */ + public saveToHistory(debounce: boolean = true): void { + // Get current cursor position if a block is focused + let cursorPosition: { blockId: string; offset: number } | undefined; + + if (this.selectedBlockId) { + const blockWrapper = this.shadowRoot?.querySelector(`[data-block-id="${this.selectedBlockId}"]`); + const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any; + if (blockComponent && typeof blockComponent.getCursorPosition === 'function') { + const editableElement = blockComponent.shadowRoot?.querySelector('.block') as HTMLElement; + if (editableElement) { + const offset = blockComponent.getCursorPosition(editableElement); + if (offset !== null) { + cursorPosition = { + blockId: this.selectedBlockId, + offset + }; + } + } + } + } + + if (debounce) { + this.history.saveState(this.blocks, this.selectedBlockId, cursorPosition); + } else { + this.history.saveCheckpoint(this.blocks, this.selectedBlockId, cursorPosition); + } + } } \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts b/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts index 80256da..b41fb9c 100644 --- a/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts +++ b/ts_web/elements/wysiwyg/dees-wysiwyg-block.ts @@ -506,7 +506,7 @@ export class DeesWysiwygBlock extends DeesElement { /** * Get cursor position in the editable element */ - private getCursorPosition(element: HTMLElement): number | null { + public getCursorPosition(element: HTMLElement): number | null { // Get parent wysiwyg component's shadow root const parentComponent = this.closest('dees-input-wysiwyg'); const parentShadowRoot = parentComponent?.shadowRoot; @@ -765,8 +765,10 @@ export class DeesWysiwygBlock extends DeesElement { cursorPos, beforeHtml, beforeLength: beforeHtml.length, + beforeHtmlPreview: beforeHtml.substring(0, 100) + (beforeHtml.length > 100 ? '...' : ''), afterHtml, - afterLength: afterHtml.length + afterLength: afterHtml.length, + afterHtmlPreview: afterHtml.substring(0, 100) + (afterHtml.length > 100 ? '...' : '') }); return { diff --git a/ts_web/elements/wysiwyg/index.ts b/ts_web/elements/wysiwyg/index.ts index b0c13c7..138fcf8 100644 --- a/ts_web/elements/wysiwyg/index.ts +++ b/ts_web/elements/wysiwyg/index.ts @@ -12,6 +12,7 @@ export * from './wysiwyg.inputhandler.js'; export * from './wysiwyg.keyboardhandler.js'; export * from './wysiwyg.dragdrophandler.js'; export * from './wysiwyg.modalmanager.js'; +export * from './wysiwyg.history.js'; export * from './dees-wysiwyg-block.js'; export * from './dees-slash-menu.js'; export * from './dees-formatting-menu.js'; \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/wysiwyg.blockoperations.ts b/ts_web/elements/wysiwyg/wysiwyg.blockoperations.ts index 4a16494..15e8818 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.blockoperations.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.blockoperations.ts @@ -59,6 +59,9 @@ export class WysiwygBlockOperations { * Removes a block by its ID */ removeBlock(blockId: string): void { + // Save checkpoint before deletion + this.component.saveToHistory(false); + this.component.blocks = this.component.blocks.filter((b: IBlock) => b.id !== blockId); // Remove the block element programmatically if we have the editor @@ -120,6 +123,9 @@ export class WysiwygBlockOperations { transformBlock(blockId: string, newType: IBlock['type'], metadata?: any): void { const block = this.findBlock(blockId); if (block) { + // Save checkpoint before transformation + this.component.saveToHistory(false); + block.type = newType; block.content = ''; if (metadata) { diff --git a/ts_web/elements/wysiwyg/wysiwyg.history.ts b/ts_web/elements/wysiwyg/wysiwyg.history.ts new file mode 100644 index 0000000..c7e6494 --- /dev/null +++ b/ts_web/elements/wysiwyg/wysiwyg.history.ts @@ -0,0 +1,167 @@ +import { type IBlock } from './wysiwyg.types.js'; + +export interface IHistoryState { + blocks: IBlock[]; + selectedBlockId: string | null; + cursorPosition?: { + blockId: string; + offset: number; + }; + timestamp: number; +} + +export class WysiwygHistory { + private history: IHistoryState[] = []; + private currentIndex: number = -1; + private maxHistorySize: number = 50; + private lastSaveTime: number = 0; + private saveDebounceMs: number = 500; // Debounce saves to avoid too many snapshots + + constructor() { + // Initialize with empty state + this.history = []; + this.currentIndex = -1; + } + + /** + * Save current state to history + */ + saveState(blocks: IBlock[], selectedBlockId: string | null, cursorPosition?: { blockId: string; offset: number }): void { + const now = Date.now(); + + // Debounce rapid changes (like typing) + if (now - this.lastSaveTime < this.saveDebounceMs && this.currentIndex >= 0) { + // Update the current state instead of creating a new one + this.history[this.currentIndex] = { + blocks: this.cloneBlocks(blocks), + selectedBlockId, + cursorPosition: cursorPosition ? { ...cursorPosition } : undefined, + timestamp: now + }; + return; + } + + // Remove any states after current index (when we save after undoing) + if (this.currentIndex < this.history.length - 1) { + this.history = this.history.slice(0, this.currentIndex + 1); + } + + // Add new state + const newState: IHistoryState = { + blocks: this.cloneBlocks(blocks), + selectedBlockId, + cursorPosition: cursorPosition ? { ...cursorPosition } : undefined, + timestamp: now + }; + + this.history.push(newState); + this.currentIndex++; + + // Limit history size + if (this.history.length > this.maxHistorySize) { + this.history.shift(); + this.currentIndex--; + } + + this.lastSaveTime = now; + } + + /** + * Force save a checkpoint (useful for operations like block deletion) + */ + saveCheckpoint(blocks: IBlock[], selectedBlockId: string | null, cursorPosition?: { blockId: string; offset: number }): void { + this.lastSaveTime = 0; // Reset debounce + this.saveState(blocks, selectedBlockId, cursorPosition); + } + + /** + * Undo to previous state + */ + undo(): IHistoryState | null { + if (!this.canUndo()) { + return null; + } + + this.currentIndex--; + return this.cloneState(this.history[this.currentIndex]); + } + + /** + * Redo to next state + */ + redo(): IHistoryState | null { + if (!this.canRedo()) { + return null; + } + + this.currentIndex++; + return this.cloneState(this.history[this.currentIndex]); + } + + /** + * Check if undo is available + */ + canUndo(): boolean { + return this.currentIndex > 0; + } + + /** + * Check if redo is available + */ + canRedo(): boolean { + return this.currentIndex < this.history.length - 1; + } + + /** + * Get current state + */ + getCurrentState(): IHistoryState | null { + if (this.currentIndex >= 0 && this.currentIndex < this.history.length) { + return this.cloneState(this.history[this.currentIndex]); + } + return null; + } + + /** + * Clear history + */ + clear(): void { + this.history = []; + this.currentIndex = -1; + this.lastSaveTime = 0; + } + + /** + * Deep clone blocks + */ + private cloneBlocks(blocks: IBlock[]): IBlock[] { + return blocks.map(block => ({ + ...block, + metadata: block.metadata ? { ...block.metadata } : undefined + })); + } + + /** + * Clone a history state + */ + private cloneState(state: IHistoryState): IHistoryState { + return { + blocks: this.cloneBlocks(state.blocks), + selectedBlockId: state.selectedBlockId, + cursorPosition: state.cursorPosition ? { ...state.cursorPosition } : undefined, + timestamp: state.timestamp + }; + } + + /** + * Get history info for debugging + */ + getHistoryInfo(): { size: number; currentIndex: number; canUndo: boolean; canRedo: boolean } { + return { + size: this.history.length, + currentIndex: this.currentIndex, + canUndo: this.canUndo(), + canRedo: this.canRedo() + }; + } +} \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/wysiwyg.interfaces.ts b/ts_web/elements/wysiwyg/wysiwyg.interfaces.ts index c27b4d2..4edc164 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.interfaces.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.interfaces.ts @@ -33,6 +33,7 @@ export interface IWysiwygComponent { updateBlockElement(blockId: string): void; handleDrop(e: DragEvent, targetBlock: IBlock): void; renderBlocksProgrammatically(): void; + saveToHistory(debounce?: boolean): void; // Handlers blockOperations: IBlockOperations; diff --git a/ts_web/elements/wysiwyg/wysiwyg.keyboardhandler.ts b/ts_web/elements/wysiwyg/wysiwyg.keyboardhandler.ts index c60aea1..a83af96 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.keyboardhandler.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.keyboardhandler.ts @@ -1,5 +1,6 @@ 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; @@ -219,9 +220,94 @@ export class WysiwygKeyboardHandler { * Handles Backspace key */ private async handleBackspace(e: KeyboardEvent, block: IBlock): Promise { - if (block.content === '' && this.component.blocks.length > 1) { + const blockOps = this.component.blockOperations; + + // 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 && prevBlock.type !== 'divider') { + console.log('Backspace at start: Merging with previous block'); + + // 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 blockOps = this.component.blockOperations; const prevBlock = blockOps.getPreviousBlock(block.id); if (prevBlock) { @@ -232,84 +318,85 @@ export class WysiwygKeyboardHandler { } } } + // Otherwise, let browser handle normal backspace } /** - * Handles ArrowUp key - navigate to previous block if at beginning + * Handles ArrowUp key - navigate to previous block if at beginning or first line */ 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; + // 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; - // Check if cursor is at the beginning of the block - const isAtStart = range.startOffset === 0 && range.endOffset === 0; + // 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; - if (isAtStart) { - const firstNode = target.firstChild; - const isReallyAtStart = !firstNode || - (range.startContainer === firstNode && range.startOffset === 0) || - (range.startContainer === target && range.startOffset === 0); + // 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)) { + console.log('ArrowUp: On first line, navigating to previous block'); + e.preventDefault(); + const blockOps = this.component.blockOperations; + const prevBlock = blockOps.getPreviousBlock(block.id); - 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'); - } + if (prevBlock && prevBlock.type !== 'divider') { + console.log('ArrowUp: Focusing previous block:', prevBlock.id); + await blockOps.focusBlock(prevBlock.id, 'end'); } } + // Otherwise, let browser handle normal navigation } /** - * Handles ArrowDown key - navigate to next block if at end + * Handles ArrowDown key - navigate to next block if at end or last line */ 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; + // 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; - // Check if cursor is at the end of the block - const lastNode = target.lastChild; + // 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; - // For different block types, check if we're at the end - let isAtEnd = false; + // 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); - 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; - } + const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots); + if (!selectionInfo || !selectionInfo.collapsed) return; - if (isAtEnd) { + // Check if we're on the last line + if (this.isOnLastLine(selectionInfo, target, ...shadowRoots)) { + console.log('ArrowDown: On last line, navigating to next block'); e.preventDefault(); const blockOps = this.component.blockOperations; const nextBlock = blockOps.getNextBlock(block.id); if (nextBlock && nextBlock.type !== 'divider') { + console.log('ArrowDown: Focusing next block:', nextBlock.id); await blockOps.focusBlock(nextBlock.id, 'start'); } } + // Otherwise, let browser handle normal navigation } /** @@ -332,29 +419,38 @@ export class WysiwygKeyboardHandler { * 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); + // 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; - // 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; + // 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); + console.log('ArrowLeft: Cursor position:', cursorPos, 'in block:', block.id); + + if (cursorPos === 0) { + const blockOps = this.component.blockOperations; + const prevBlock = blockOps.getPreviousBlock(block.id); + console.log('ArrowLeft: At start, previous block:', prevBlock?.id); - // 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'); - } + if (prevBlock && prevBlock.type !== 'divider') { + e.preventDefault(); + await blockOps.focusBlock(prevBlock.id, 'end'); } } // Otherwise, let the browser handle normal left arrow navigation @@ -364,40 +460,37 @@ export class WysiwygKeyboardHandler { * 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; + // 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; - // Check if cursor is at the very end - if (range.collapsed) { - const textLength = target.textContent?.length || 0; - let isAtEnd = false; + // 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 (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'); - } + if (nextBlock && nextBlock.type !== 'divider') { + e.preventDefault(); + await blockOps.focusBlock(nextBlock.id, 'start'); } } // Otherwise, let the browser handle normal right arrow navigation @@ -407,4 +500,78 @@ export class WysiwygKeyboardHandler { * 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; + } + } } \ No newline at end of file