Files
dees-catalog/ts_web/elements/wysiwyg/wysiwyg.keyboardhandler.ts

410 lines
13 KiB
TypeScript
Raw Normal View History

2025-06-24 08:19:53 +00:00
import { type IBlock } from './wysiwyg.types.js';
2025-06-24 13:41:12 +00:00
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
2025-06-24 08:19:53 +00:00
export class WysiwygKeyboardHandler {
2025-06-24 13:41:12 +00:00
private component: IWysiwygComponent;
2025-06-24 08:19:53 +00:00
2025-06-24 13:41:12 +00:00
constructor(component: IWysiwygComponent) {
2025-06-24 08:19:53 +00:00
this.component = component;
}
/**
* Handles keyboard events for blocks
*/
2025-06-24 10:45:06 +00:00
async handleBlockKeyDown(e: KeyboardEvent, block: IBlock): Promise<void> {
2025-06-24 08:19:53 +00:00
// Handle slash menu navigation
2025-06-24 10:45:06 +00:00
if (this.component.slashMenu.visible && this.isSlashMenuKey(e.key)) {
this.component.handleSlashMenuKeyboard(e);
2025-06-24 08:19:53 +00:00
return;
}
// Handle formatting shortcuts
if (this.handleFormattingShortcuts(e)) {
return;
}
// Handle special keys
switch (e.key) {
case 'Tab':
this.handleTab(e, block);
break;
case 'Enter':
2025-06-24 10:45:06 +00:00
await this.handleEnter(e, block);
2025-06-24 08:19:53 +00:00
break;
case 'Backspace':
2025-06-24 10:45:06 +00:00
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);
2025-06-24 08:19:53 +00:00
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();
2025-06-24 10:45:06 +00:00
// Use Promise to ensure focus is maintained
Promise.resolve().then(() => this.component.applyFormat('bold'));
2025-06-24 08:19:53 +00:00
return true;
case 'i':
e.preventDefault();
2025-06-24 10:45:06 +00:00
Promise.resolve().then(() => this.component.applyFormat('italic'));
2025-06-24 08:19:53 +00:00
return true;
case 'u':
e.preventDefault();
2025-06-24 10:45:06 +00:00
Promise.resolve().then(() => this.component.applyFormat('underline'));
2025-06-24 08:19:53 +00:00
return true;
case 'k':
e.preventDefault();
2025-06-24 10:45:06 +00:00
Promise.resolve().then(() => this.component.applyFormat('link'));
2025-06-24 08:19:53 +00:00
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();
2025-06-24 10:45:06 +00:00
// 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);
}
2025-06-24 08:19:53 +00:00
} else if (block.type === 'list') {
// Future: implement list indentation
e.preventDefault();
}
}
/**
* Handles Enter key
*/
2025-06-24 10:45:06 +00:00
private async handleEnter(e: KeyboardEvent, block: IBlock): Promise<void> {
2025-06-24 08:19:53 +00:00
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();
2025-06-24 10:45:06 +00:00
await blockOps.insertBlockAfter(block, newBlock);
2025-06-24 08:19:53 +00:00
}
// Normal Enter in code blocks creates new line (let browser handle it)
return;
}
if (!e.shiftKey) {
if (block.type === 'list') {
2025-06-24 10:45:06 +00:00
await this.handleEnterInList(e, block);
2025-06-24 08:19:53 +00:00
} else {
2025-06-24 10:45:06 +00:00
// Split content at cursor position
2025-06-24 08:19:53 +00:00
e.preventDefault();
2025-06-24 10:45:06 +00:00
2025-06-24 13:41:12 +00:00
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);
2025-06-24 10:45:06 +00:00
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
2025-06-24 13:41:12 +00:00
console.log('Found block component:', blockComponent, 'has getSplitContent:', !!blockComponent?.getSplitContent);
2025-06-24 10:45:06 +00:00
if (blockComponent && blockComponent.getSplitContent) {
2025-06-24 13:41:12 +00:00
console.log('Calling getSplitContent...');
2025-06-24 10:45:06 +00:00
const splitContent = blockComponent.getSplitContent();
2025-06-24 13:41:12 +00:00
console.log('Enter key split content result:', {
hasSplitContent: !!splitContent,
beforeLength: splitContent?.before?.length || 0,
afterLength: splitContent?.after?.length || 0,
splitContent
});
2025-06-24 10:45:06 +00:00
if (splitContent) {
2025-06-24 13:41:12 +00:00
console.log('Updating current block with before content...');
2025-06-24 10:45:06 +00:00
// Update current block with content before cursor
blockComponent.setContent(splitContent.before);
block.content = splitContent.before;
2025-06-24 13:41:12 +00:00
console.log('Creating new block with after content...');
2025-06-24 10:45:06 +00:00
// Create new block with content after cursor
const newBlock = blockOps.createBlock('paragraph', splitContent.after);
2025-06-24 13:41:12 +00:00
console.log('Inserting new block...');
2025-06-24 10:45:06 +00:00
// Insert the new block
await blockOps.insertBlockAfter(block, newBlock);
// Update the value after both blocks are set
this.component.updateValue();
2025-06-24 13:41:12 +00:00
console.log('Enter key handling complete');
2025-06-24 10:45:06 +00:00
} else {
// Fallback - just create empty block
2025-06-24 13:41:12 +00:00
console.log('No split content returned, creating empty block');
2025-06-24 10:45:06 +00:00
const newBlock = blockOps.createBlock();
await blockOps.insertBlockAfter(block, newBlock);
}
} else {
// No block component or method, just create empty block
2025-06-24 13:41:12 +00:00
console.log('No getSplitContent method, creating empty block');
2025-06-24 10:45:06 +00:00
const newBlock = blockOps.createBlock();
await blockOps.insertBlockAfter(block, newBlock);
}
2025-06-24 08:19:53 +00:00
}
}
// Shift+Enter creates line break (let browser handle it)
}
/**
* Handles Enter key in list blocks
*/
2025-06-24 10:45:06 +00:00
private async handleEnterInList(e: KeyboardEvent, block: IBlock): Promise<void> {
2025-06-24 08:19:53 +00:00
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();
2025-06-24 10:45:06 +00:00
await blockOps.insertBlockAfter(block, newBlock);
2025-06-24 08:19:53 +00:00
}
// Otherwise, let browser create new list item
}
}
/**
* Handles Backspace key
*/
2025-06-24 10:45:06 +00:00
private async handleBackspace(e: KeyboardEvent, block: IBlock): Promise<void> {
2025-06-24 08:19:53 +00:00
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);
2025-06-24 10:45:06 +00:00
if (prevBlock.type !== 'divider') {
await blockOps.focusBlock(prevBlock.id, 'end');
}
2025-06-24 08:19:53 +00:00
}
}
}
/**
2025-06-24 10:45:06 +00:00
* Handles ArrowUp key - navigate to previous block if at beginning
2025-06-24 08:19:53 +00:00
*/
2025-06-24 10:45:06 +00:00
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;
2025-06-24 08:19:53 +00:00
2025-06-24 10:45:06 +00:00
// 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) {
2025-06-24 08:19:53 +00:00
e.preventDefault();
2025-06-24 10:45:06 +00:00
const blockOps = this.component.blockOperations;
const prevBlock = blockOps.getPreviousBlock(block.id);
2025-06-24 08:19:53 +00:00
2025-06-24 10:45:06 +00:00
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<void> {
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<void> {
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);
2025-06-24 08:19:53 +00:00
2025-06-24 10:45:06 +00:00
if (prevBlock && prevBlock.type !== 'divider') {
e.preventDefault();
await blockOps.focusBlock(prevBlock.id, 'end');
2025-06-24 08:19:53 +00:00
}
2025-06-24 10:45:06 +00:00
}
}
// 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<void> {
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);
2025-06-24 08:19:53 +00:00
2025-06-24 10:45:06 +00:00
if (nextBlock && nextBlock.type !== 'divider') {
e.preventDefault();
await blockOps.focusBlock(nextBlock.id, 'start');
}
}
2025-06-24 08:19:53 +00:00
}
2025-06-24 10:45:06 +00:00
// Otherwise, let the browser handle normal right arrow navigation
2025-06-24 08:19:53 +00:00
}
2025-06-24 10:45:06 +00:00
/**
* Handles slash menu keyboard navigation
* Note: This is now handled by the component directly
*/
2025-06-24 08:19:53 +00:00
}