feat(wysiwyg): implement backspace

This commit is contained in:
Juergen Kunz
2025-06-24 15:52:28 +00:00
parent 83f153f654
commit ca525ce7e3
7 changed files with 563 additions and 108 deletions

View File

@ -23,6 +23,7 @@ import {
WysiwygKeyboardHandler, WysiwygKeyboardHandler,
WysiwygDragDropHandler, WysiwygDragDropHandler,
WysiwygModalManager, WysiwygModalManager,
WysiwygHistory,
DeesSlashMenu, DeesSlashMenu,
DeesFormattingMenu DeesFormattingMenu
} from './index.js'; } from './index.js';
@ -89,6 +90,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
private inputHandler: WysiwygInputHandler; private inputHandler: WysiwygInputHandler;
private keyboardHandler: WysiwygKeyboardHandler; private keyboardHandler: WysiwygKeyboardHandler;
private dragDropHandler: WysiwygDragDropHandler; private dragDropHandler: WysiwygDragDropHandler;
private history: WysiwygHistory;
public static styles = [ public static styles = [
...DeesInputBase.baseStyles, ...DeesInputBase.baseStyles,
@ -103,6 +105,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
this.inputHandler = new WysiwygInputHandler(this); this.inputHandler = new WysiwygInputHandler(this);
this.keyboardHandler = new WysiwygKeyboardHandler(this); this.keyboardHandler = new WysiwygKeyboardHandler(this);
this.dragDropHandler = new WysiwygDragDropHandler(this); this.dragDropHandler = new WysiwygDragDropHandler(this);
this.history = new WysiwygHistory();
} }
async connectedCallback() { async connectedCallback() {
@ -143,6 +146,27 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
} }
}); });
// 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 // Render blocks programmatically
this.renderBlocksProgrammatically(); this.renderBlocksProgrammatically();
} }
@ -516,6 +540,9 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
this.value = WysiwygConverters.getMarkdownOutput(this.blocks); this.value = WysiwygConverters.getMarkdownOutput(this.blocks);
} }
this.changeSubject.next(this.value); this.changeSubject.next(this.value);
// Save to history (debounced)
this.saveToHistory(true);
} }
public getValue(): string { public getValue(): string {
@ -879,4 +906,88 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
}, 100); }, 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);
}
}
} }

View File

@ -506,7 +506,7 @@ export class DeesWysiwygBlock extends DeesElement {
/** /**
* Get cursor position in the editable element * 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 // Get parent wysiwyg component's shadow root
const parentComponent = this.closest('dees-input-wysiwyg'); const parentComponent = this.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot; const parentShadowRoot = parentComponent?.shadowRoot;
@ -765,8 +765,10 @@ export class DeesWysiwygBlock extends DeesElement {
cursorPos, cursorPos,
beforeHtml, beforeHtml,
beforeLength: beforeHtml.length, beforeLength: beforeHtml.length,
beforeHtmlPreview: beforeHtml.substring(0, 100) + (beforeHtml.length > 100 ? '...' : ''),
afterHtml, afterHtml,
afterLength: afterHtml.length afterLength: afterHtml.length,
afterHtmlPreview: afterHtml.substring(0, 100) + (afterHtml.length > 100 ? '...' : '')
}); });
return { return {

View File

@ -12,6 +12,7 @@ export * from './wysiwyg.inputhandler.js';
export * from './wysiwyg.keyboardhandler.js'; export * from './wysiwyg.keyboardhandler.js';
export * from './wysiwyg.dragdrophandler.js'; export * from './wysiwyg.dragdrophandler.js';
export * from './wysiwyg.modalmanager.js'; export * from './wysiwyg.modalmanager.js';
export * from './wysiwyg.history.js';
export * from './dees-wysiwyg-block.js'; export * from './dees-wysiwyg-block.js';
export * from './dees-slash-menu.js'; export * from './dees-slash-menu.js';
export * from './dees-formatting-menu.js'; export * from './dees-formatting-menu.js';

View File

@ -59,6 +59,9 @@ export class WysiwygBlockOperations {
* Removes a block by its ID * Removes a block by its ID
*/ */
removeBlock(blockId: string): void { removeBlock(blockId: string): void {
// Save checkpoint before deletion
this.component.saveToHistory(false);
this.component.blocks = this.component.blocks.filter((b: IBlock) => b.id !== blockId); this.component.blocks = this.component.blocks.filter((b: IBlock) => b.id !== blockId);
// Remove the block element programmatically if we have the editor // 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 { transformBlock(blockId: string, newType: IBlock['type'], metadata?: any): void {
const block = this.findBlock(blockId); const block = this.findBlock(blockId);
if (block) { if (block) {
// Save checkpoint before transformation
this.component.saveToHistory(false);
block.type = newType; block.type = newType;
block.content = ''; block.content = '';
if (metadata) { if (metadata) {

View File

@ -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()
};
}
}

View File

@ -33,6 +33,7 @@ export interface IWysiwygComponent {
updateBlockElement(blockId: string): void; updateBlockElement(blockId: string): void;
handleDrop(e: DragEvent, targetBlock: IBlock): void; handleDrop(e: DragEvent, targetBlock: IBlock): void;
renderBlocksProgrammatically(): void; renderBlocksProgrammatically(): void;
saveToHistory(debounce?: boolean): void;
// Handlers // Handlers
blockOperations: IBlockOperations; blockOperations: IBlockOperations;

View File

@ -1,5 +1,6 @@
import { type IBlock } from './wysiwyg.types.js'; import { type IBlock } from './wysiwyg.types.js';
import { type IWysiwygComponent } from './wysiwyg.interfaces.js'; import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
import { WysiwygSelection } from './wysiwyg.selection.js';
export class WysiwygKeyboardHandler { export class WysiwygKeyboardHandler {
private component: IWysiwygComponent; private component: IWysiwygComponent;
@ -219,9 +220,94 @@ export class WysiwygKeyboardHandler {
* Handles Backspace key * Handles Backspace key
*/ */
private async handleBackspace(e: KeyboardEvent, block: IBlock): Promise<void> { private async handleBackspace(e: KeyboardEvent, block: IBlock): Promise<void> {
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(); e.preventDefault();
const blockOps = this.component.blockOperations;
const prevBlock = blockOps.getPreviousBlock(block.id); const prevBlock = blockOps.getPreviousBlock(block.id);
if (prevBlock) { 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<void> { private async handleArrowUp(e: KeyboardEvent, block: IBlock): Promise<void> {
const selection = window.getSelection(); // Get the block component from the wysiwyg component's shadow DOM
if (!selection || selection.rangeCount === 0) return; const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
if (!blockComponent || !blockComponent.shadowRoot) return;
const range = selection.getRangeAt(0); // Get the actual editable element (code blocks have .block.code)
const target = e.target as HTMLElement; const target = block.type === 'code'
? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
if (!target) return;
// Check if cursor is at the beginning of the block // Get selection info with proper shadow DOM support
const isAtStart = range.startOffset === 0 && range.endOffset === 0; const parentComponent = blockComponent.closest('dees-input-wysiwyg');
const shadowRoots: ShadowRoot[] = [];
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
shadowRoots.push(blockComponent.shadowRoot);
if (isAtStart) { const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
const firstNode = target.firstChild; if (!selectionInfo || !selectionInfo.collapsed) return;
const isReallyAtStart = !firstNode ||
(range.startContainer === firstNode && range.startOffset === 0) ||
(range.startContainer === target && range.startOffset === 0);
if (isReallyAtStart) { // Check if we're on the first line
e.preventDefault(); if (this.isOnFirstLine(selectionInfo, target, ...shadowRoots)) {
const blockOps = this.component.blockOperations; console.log('ArrowUp: On first line, navigating to previous block');
const prevBlock = blockOps.getPreviousBlock(block.id); e.preventDefault();
const blockOps = this.component.blockOperations;
const prevBlock = blockOps.getPreviousBlock(block.id);
if (prevBlock && prevBlock.type !== 'divider') { if (prevBlock && prevBlock.type !== 'divider') {
await blockOps.focusBlock(prevBlock.id, 'end'); 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<void> { private async handleArrowDown(e: KeyboardEvent, block: IBlock): Promise<void> {
const selection = window.getSelection(); // Get the block component from the wysiwyg component's shadow DOM
if (!selection || selection.rangeCount === 0) return; const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
if (!blockComponent || !blockComponent.shadowRoot) return;
const range = selection.getRangeAt(0); // Get the actual editable element (code blocks have .block.code)
const target = e.target as HTMLElement; const target = block.type === 'code'
? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
if (!target) return;
// Check if cursor is at the end of the block // Get selection info with proper shadow DOM support
const lastNode = target.lastChild; const parentComponent = blockComponent.closest('dees-input-wysiwyg');
const shadowRoots: ShadowRoot[] = [];
if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
shadowRoots.push(blockComponent.shadowRoot);
// For different block types, check if we're at the end const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
let isAtEnd = false; if (!selectionInfo || !selectionInfo.collapsed) return;
if (!lastNode) { // Check if we're on the last line
// Empty block if (this.isOnLastLine(selectionInfo, target, ...shadowRoots)) {
isAtEnd = true; console.log('ArrowDown: On last line, navigating to next block');
} 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(); e.preventDefault();
const blockOps = this.component.blockOperations; const blockOps = this.component.blockOperations;
const nextBlock = blockOps.getNextBlock(block.id); const nextBlock = blockOps.getNextBlock(block.id);
if (nextBlock && nextBlock.type !== 'divider') { if (nextBlock && nextBlock.type !== 'divider') {
console.log('ArrowDown: Focusing next block:', nextBlock.id);
await blockOps.focusBlock(nextBlock.id, 'start'); 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 * Handles ArrowLeft key - navigate to previous block if at beginning
*/ */
private async handleArrowLeft(e: KeyboardEvent, block: IBlock): Promise<void> { private async handleArrowLeft(e: KeyboardEvent, block: IBlock): Promise<void> {
const selection = window.getSelection(); // Get the block component from the wysiwyg component's shadow DOM
if (!selection || selection.rangeCount === 0) return; const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
if (!blockComponent || !blockComponent.shadowRoot) return;
const range = selection.getRangeAt(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;
// Check if cursor is at the very beginning (collapsed and at offset 0) // Get selection info with proper shadow DOM support
if (range.collapsed && range.startOffset === 0) { const parentComponent = blockComponent.closest('dees-input-wysiwyg');
const target = e.target as HTMLElement; const shadowRoots: ShadowRoot[] = [];
const firstNode = target.firstChild; if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
shadowRoots.push(blockComponent.shadowRoot);
// Verify we're really at the start const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
const isAtStart = !firstNode || if (!selectionInfo || !selectionInfo.collapsed) return;
(range.startContainer === firstNode) ||
(range.startContainer === target);
if (isAtStart) { // Check if cursor is at the beginning of the block
const blockOps = this.component.blockOperations; const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
const prevBlock = blockOps.getPreviousBlock(block.id); console.log('ArrowLeft: Cursor position:', cursorPos, 'in block:', block.id);
if (prevBlock && prevBlock.type !== 'divider') { if (cursorPos === 0) {
e.preventDefault(); const blockOps = this.component.blockOperations;
await blockOps.focusBlock(prevBlock.id, 'end'); const prevBlock = blockOps.getPreviousBlock(block.id);
} console.log('ArrowLeft: At start, previous block:', prevBlock?.id);
if (prevBlock && prevBlock.type !== 'divider') {
e.preventDefault();
await blockOps.focusBlock(prevBlock.id, 'end');
} }
} }
// Otherwise, let the browser handle normal left arrow navigation // 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 * Handles ArrowRight key - navigate to next block if at end
*/ */
private async handleArrowRight(e: KeyboardEvent, block: IBlock): Promise<void> { private async handleArrowRight(e: KeyboardEvent, block: IBlock): Promise<void> {
const selection = window.getSelection(); // Get the block component from the wysiwyg component's shadow DOM
if (!selection || selection.rangeCount === 0) return; const blockWrapper = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block');
if (!blockComponent || !blockComponent.shadowRoot) return;
const range = selection.getRangeAt(0); // Get the actual editable element (code blocks have .block.code)
const target = e.target as HTMLElement; const target = block.type === 'code'
? blockComponent.shadowRoot.querySelector('.block.code') as HTMLElement
: blockComponent.shadowRoot.querySelector('.block') as HTMLElement;
if (!target) return;
// Check if cursor is at the very end // Get selection info with proper shadow DOM support
if (range.collapsed) { const parentComponent = blockComponent.closest('dees-input-wysiwyg');
const textLength = target.textContent?.length || 0; const shadowRoots: ShadowRoot[] = [];
let isAtEnd = false; if (parentComponent?.shadowRoot) shadowRoots.push(parentComponent.shadowRoot);
shadowRoots.push(blockComponent.shadowRoot);
if (textLength === 0) { const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
// Empty block if (!selectionInfo || !selectionInfo.collapsed) return;
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) { // Check if cursor is at the end of the block
const blockOps = this.component.blockOperations; const cursorPos = WysiwygSelection.getCursorPositionInElement(target, ...shadowRoots);
const nextBlock = blockOps.getNextBlock(block.id); const textLength = target.textContent?.length || 0;
if (nextBlock && nextBlock.type !== 'divider') { if (cursorPos === textLength) {
e.preventDefault(); const blockOps = this.component.blockOperations;
await blockOps.focusBlock(nextBlock.id, 'start'); 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 // Otherwise, let the browser handle normal right arrow navigation
@ -407,4 +500,78 @@ export class WysiwygKeyboardHandler {
* Handles slash menu keyboard navigation * Handles slash menu keyboard navigation
* Note: This is now handled by the component directly * 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;
}
}
} }