198 lines
5.3 KiB
TypeScript
198 lines
5.3 KiB
TypeScript
![]() |
import { type IBlock } from './wysiwyg.types.js';
|
||
|
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
||
|
import { WysiwygBlockOperations } from './wysiwyg.blockoperations.js';
|
||
|
|
||
|
export class WysiwygKeyboardHandler {
|
||
|
private component: any;
|
||
|
|
||
|
constructor(component: any) {
|
||
|
this.component = component;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Handles keyboard events for blocks
|
||
|
*/
|
||
|
handleBlockKeyDown(e: KeyboardEvent, block: IBlock): void {
|
||
|
// Handle slash menu navigation
|
||
|
if (this.component.showSlashMenu && this.isSlashMenuKey(e.key)) {
|
||
|
this.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':
|
||
|
this.handleEnter(e, block);
|
||
|
break;
|
||
|
case 'Backspace':
|
||
|
this.handleBackspace(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();
|
||
|
this.component.applyFormat('bold');
|
||
|
return true;
|
||
|
case 'i':
|
||
|
e.preventDefault();
|
||
|
this.component.applyFormat('italic');
|
||
|
return true;
|
||
|
case 'u':
|
||
|
e.preventDefault();
|
||
|
this.component.applyFormat('underline');
|
||
|
return true;
|
||
|
case 'k':
|
||
|
e.preventDefault();
|
||
|
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();
|
||
|
document.execCommand('insertText', false, ' ');
|
||
|
} else if (block.type === 'list') {
|
||
|
// Future: implement list indentation
|
||
|
e.preventDefault();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Handles Enter key
|
||
|
*/
|
||
|
private handleEnter(e: KeyboardEvent, block: IBlock): void {
|
||
|
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();
|
||
|
blockOps.insertBlockAfter(block, newBlock);
|
||
|
}
|
||
|
// Normal Enter in code blocks creates new line (let browser handle it)
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (!e.shiftKey) {
|
||
|
if (block.type === 'list') {
|
||
|
this.handleEnterInList(e, block);
|
||
|
} else {
|
||
|
// Create new paragraph block
|
||
|
e.preventDefault();
|
||
|
const newBlock = blockOps.createBlock();
|
||
|
blockOps.insertBlockAfter(block, newBlock);
|
||
|
}
|
||
|
}
|
||
|
// Shift+Enter creates line break (let browser handle it)
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Handles Enter key in list blocks
|
||
|
*/
|
||
|
private handleEnterInList(e: KeyboardEvent, block: IBlock): void {
|
||
|
const target = e.target as HTMLDivElement;
|
||
|
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();
|
||
|
blockOps.insertBlockAfter(block, newBlock);
|
||
|
}
|
||
|
// Otherwise, let browser create new list item
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Handles Backspace key
|
||
|
*/
|
||
|
private handleBackspace(e: KeyboardEvent, block: IBlock): void {
|
||
|
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);
|
||
|
|
||
|
setTimeout(() => {
|
||
|
if (prevBlock.type !== 'divider') {
|
||
|
blockOps.focusBlock(prevBlock.id, 'end');
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Handles slash menu keyboard navigation
|
||
|
*/
|
||
|
private handleSlashMenuKeyboard(e: KeyboardEvent): void {
|
||
|
const menuItems = this.component.getFilteredMenuItems();
|
||
|
|
||
|
switch(e.key) {
|
||
|
case 'ArrowDown':
|
||
|
e.preventDefault();
|
||
|
this.component.slashMenuSelectedIndex =
|
||
|
(this.component.slashMenuSelectedIndex + 1) % menuItems.length;
|
||
|
break;
|
||
|
|
||
|
case 'ArrowUp':
|
||
|
e.preventDefault();
|
||
|
this.component.slashMenuSelectedIndex =
|
||
|
this.component.slashMenuSelectedIndex === 0
|
||
|
? menuItems.length - 1
|
||
|
: this.component.slashMenuSelectedIndex - 1;
|
||
|
break;
|
||
|
|
||
|
case 'Enter':
|
||
|
e.preventDefault();
|
||
|
if (menuItems[this.component.slashMenuSelectedIndex]) {
|
||
|
this.component.insertBlock(
|
||
|
menuItems[this.component.slashMenuSelectedIndex].type as IBlock['type']
|
||
|
);
|
||
|
}
|
||
|
break;
|
||
|
|
||
|
case 'Escape':
|
||
|
e.preventDefault();
|
||
|
this.component.closeSlashMenu();
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
}
|