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

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