797 lines
30 KiB
TypeScript
797 lines
30 KiB
TypeScript
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;
|
|
|
|
constructor(component: IWysiwygComponent) {
|
|
this.component = component;
|
|
}
|
|
|
|
/**
|
|
* Handles keyboard events for blocks
|
|
*/
|
|
async handleBlockKeyDown(e: KeyboardEvent, block: IBlock): Promise<void> {
|
|
// Handle slash menu navigation
|
|
if (this.component.slashMenu.visible && this.isSlashMenuKey(e.key)) {
|
|
this.component.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':
|
|
await this.handleEnter(e, block);
|
|
break;
|
|
case 'Backspace':
|
|
await this.handleBackspace(e, block);
|
|
break;
|
|
case 'Delete':
|
|
await this.handleDelete(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);
|
|
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();
|
|
// Use Promise to ensure focus is maintained
|
|
Promise.resolve().then(() => this.component.applyFormat('bold'));
|
|
return true;
|
|
case 'i':
|
|
e.preventDefault();
|
|
Promise.resolve().then(() => this.component.applyFormat('italic'));
|
|
return true;
|
|
case 'u':
|
|
e.preventDefault();
|
|
Promise.resolve().then(() => this.component.applyFormat('underline'));
|
|
return true;
|
|
case 'k':
|
|
e.preventDefault();
|
|
Promise.resolve().then(() => 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();
|
|
// 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);
|
|
}
|
|
} else if (block.type === 'list') {
|
|
// Future: implement list indentation
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles Enter key
|
|
*/
|
|
private async handleEnter(e: KeyboardEvent, block: IBlock): Promise<void> {
|
|
const blockOps = this.component.blockOperations;
|
|
|
|
// For non-editable blocks, create a new paragraph after
|
|
if (block.type === 'divider' || block.type === 'image') {
|
|
e.preventDefault();
|
|
const newBlock = blockOps.createBlock();
|
|
await blockOps.insertBlockAfter(block, newBlock);
|
|
return;
|
|
}
|
|
|
|
if (block.type === 'code') {
|
|
if (e.shiftKey) {
|
|
// Shift+Enter in code blocks creates a new block
|
|
e.preventDefault();
|
|
const newBlock = blockOps.createBlock();
|
|
await blockOps.insertBlockAfter(block, newBlock);
|
|
}
|
|
// Normal Enter in code blocks creates new line (let browser handle it)
|
|
return;
|
|
}
|
|
|
|
if (!e.shiftKey) {
|
|
if (block.type === 'list') {
|
|
await this.handleEnterInList(e, block);
|
|
} else {
|
|
// Split content at cursor position
|
|
e.preventDefault();
|
|
|
|
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);
|
|
|
|
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
|
console.log('Found block component:', blockComponent, 'has getSplitContent:', !!blockComponent?.getSplitContent);
|
|
|
|
if (blockComponent && blockComponent.getSplitContent) {
|
|
console.log('Calling getSplitContent...');
|
|
const splitContent = blockComponent.getSplitContent();
|
|
|
|
console.log('Enter key split content result:', {
|
|
hasSplitContent: !!splitContent,
|
|
beforeLength: splitContent?.before?.length || 0,
|
|
afterLength: splitContent?.after?.length || 0,
|
|
splitContent
|
|
});
|
|
|
|
if (splitContent) {
|
|
console.log('Updating current block with before content...');
|
|
// Update current block with content before cursor
|
|
blockComponent.setContent(splitContent.before);
|
|
block.content = splitContent.before;
|
|
|
|
console.log('Creating new block with after content...');
|
|
// Create new block with content after cursor
|
|
const newBlock = blockOps.createBlock('paragraph', splitContent.after);
|
|
|
|
console.log('Inserting new block...');
|
|
// Insert the new block
|
|
await blockOps.insertBlockAfter(block, newBlock);
|
|
|
|
// Update the value after both blocks are set
|
|
this.component.updateValue();
|
|
console.log('Enter key handling complete');
|
|
} else {
|
|
// Fallback - just create empty block
|
|
console.log('No split content returned, creating empty block');
|
|
const newBlock = blockOps.createBlock();
|
|
await blockOps.insertBlockAfter(block, newBlock);
|
|
}
|
|
} else {
|
|
// No block component or method, just create empty block
|
|
console.log('No getSplitContent method, creating empty block');
|
|
const newBlock = blockOps.createBlock();
|
|
await blockOps.insertBlockAfter(block, newBlock);
|
|
}
|
|
}
|
|
}
|
|
// Shift+Enter creates line break (let browser handle it)
|
|
}
|
|
|
|
/**
|
|
* Handles Enter key in list blocks
|
|
*/
|
|
private async handleEnterInList(e: KeyboardEvent, block: IBlock): Promise<void> {
|
|
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();
|
|
await blockOps.insertBlockAfter(block, newBlock);
|
|
}
|
|
// Otherwise, let browser create new list item
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles Backspace key
|
|
*/
|
|
private async handleBackspace(e: KeyboardEvent, block: IBlock): Promise<void> {
|
|
const blockOps = this.component.blockOperations;
|
|
|
|
// Handle non-editable blocks
|
|
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
|
if (nonEditableTypes.includes(block.type)) {
|
|
e.preventDefault();
|
|
|
|
// If it's the only block, delete it and create a new paragraph
|
|
if (this.component.blocks.length === 1) {
|
|
// Save state for undo
|
|
this.component.saveToHistory(false);
|
|
|
|
// Remove the block
|
|
blockOps.removeBlock(block.id);
|
|
|
|
// Create a new paragraph block
|
|
const newBlock = blockOps.createBlock('paragraph', '');
|
|
this.component.blocks = [newBlock];
|
|
|
|
// Re-render blocks
|
|
this.component.renderBlocksProgrammatically();
|
|
|
|
// Focus the new block
|
|
await blockOps.focusBlock(newBlock.id, 'start');
|
|
|
|
// Update value
|
|
this.component.updateValue();
|
|
return;
|
|
}
|
|
|
|
// Save state for undo
|
|
this.component.saveToHistory(false);
|
|
|
|
// Find the previous block to focus
|
|
const prevBlock = blockOps.getPreviousBlock(block.id);
|
|
const nextBlock = blockOps.getNextBlock(block.id);
|
|
|
|
// Remove the block
|
|
blockOps.removeBlock(block.id);
|
|
|
|
// Focus the appropriate block
|
|
if (prevBlock && prevBlock.type !== 'divider' && prevBlock.type !== 'image') {
|
|
await blockOps.focusBlock(prevBlock.id, 'end');
|
|
} else if (nextBlock && nextBlock.type !== 'divider' && nextBlock.type !== 'image') {
|
|
await blockOps.focusBlock(nextBlock.id, 'start');
|
|
} else if (prevBlock) {
|
|
// If previous block is also non-editable, just select it
|
|
await blockOps.focusBlock(prevBlock.id);
|
|
} else if (nextBlock) {
|
|
// If next block is also non-editable, just select it
|
|
await blockOps.focusBlock(nextBlock.id);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// 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) {
|
|
// If previous block is non-editable, select it first
|
|
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
|
if (nonEditableTypes.includes(prevBlock.type)) {
|
|
await blockOps.focusBlock(prevBlock.id);
|
|
return;
|
|
}
|
|
|
|
|
|
// 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 prevBlock = blockOps.getPreviousBlock(block.id);
|
|
|
|
if (prevBlock) {
|
|
blockOps.removeBlock(block.id);
|
|
|
|
if (prevBlock.type !== 'divider') {
|
|
await blockOps.focusBlock(prevBlock.id, 'end');
|
|
}
|
|
}
|
|
}
|
|
// Otherwise, let browser handle normal backspace
|
|
}
|
|
|
|
/**
|
|
* Handles Delete key
|
|
*/
|
|
private async handleDelete(e: KeyboardEvent, block: IBlock): Promise<void> {
|
|
const blockOps = this.component.blockOperations;
|
|
|
|
// Handle non-editable blocks - same as backspace
|
|
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
|
if (nonEditableTypes.includes(block.type)) {
|
|
e.preventDefault();
|
|
|
|
// If it's the only block, delete it and create a new paragraph
|
|
if (this.component.blocks.length === 1) {
|
|
// Save state for undo
|
|
this.component.saveToHistory(false);
|
|
|
|
// Remove the block
|
|
blockOps.removeBlock(block.id);
|
|
|
|
// Create a new paragraph block
|
|
const newBlock = blockOps.createBlock('paragraph', '');
|
|
this.component.blocks = [newBlock];
|
|
|
|
// Re-render blocks
|
|
this.component.renderBlocksProgrammatically();
|
|
|
|
// Focus the new block
|
|
await blockOps.focusBlock(newBlock.id, 'start');
|
|
|
|
// Update value
|
|
this.component.updateValue();
|
|
return;
|
|
}
|
|
|
|
// Save state for undo
|
|
this.component.saveToHistory(false);
|
|
|
|
// Find the previous block to focus
|
|
const prevBlock = blockOps.getPreviousBlock(block.id);
|
|
const nextBlock = blockOps.getNextBlock(block.id);
|
|
|
|
// Remove the block
|
|
blockOps.removeBlock(block.id);
|
|
|
|
// Focus the appropriate block
|
|
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
|
if (nextBlock && !nonEditableTypes.includes(nextBlock.type)) {
|
|
await blockOps.focusBlock(nextBlock.id, 'start');
|
|
} else if (prevBlock && !nonEditableTypes.includes(prevBlock.type)) {
|
|
await blockOps.focusBlock(prevBlock.id, 'end');
|
|
} else if (nextBlock) {
|
|
// If next block is also non-editable, just select it
|
|
await blockOps.focusBlock(nextBlock.id);
|
|
} else if (prevBlock) {
|
|
// If previous block is also non-editable, just select it
|
|
await blockOps.focusBlock(prevBlock.id);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// For editable blocks, check if we're at the end and next block is non-editable
|
|
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);
|
|
const textLength = target.textContent?.length || 0;
|
|
|
|
// Check if cursor is at the end of the block
|
|
if (cursorPos === textLength) {
|
|
const nextBlock = blockOps.getNextBlock(block.id);
|
|
|
|
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
|
if (nextBlock && nonEditableTypes.includes(nextBlock.type)) {
|
|
e.preventDefault();
|
|
await blockOps.focusBlock(nextBlock.id);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Otherwise, let browser handle normal delete
|
|
}
|
|
|
|
/**
|
|
* Handles ArrowUp key - navigate to previous block if at beginning or first line
|
|
*/
|
|
private async handleArrowUp(e: KeyboardEvent, block: IBlock): Promise<void> {
|
|
// For non-editable blocks, always navigate to previous block
|
|
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
|
if (nonEditableTypes.includes(block.type)) {
|
|
e.preventDefault();
|
|
const blockOps = this.component.blockOperations;
|
|
const prevBlock = blockOps.getPreviousBlock(block.id);
|
|
|
|
if (prevBlock) {
|
|
await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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 we're on the first line
|
|
if (this.isOnFirstLine(selectionInfo, target, ...shadowRoots)) {
|
|
e.preventDefault();
|
|
const blockOps = this.component.blockOperations;
|
|
const prevBlock = blockOps.getPreviousBlock(block.id);
|
|
|
|
if (prevBlock) {
|
|
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
|
await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end');
|
|
}
|
|
}
|
|
// Otherwise, let browser handle normal navigation
|
|
}
|
|
|
|
/**
|
|
* Handles ArrowDown key - navigate to next block if at end or last line
|
|
*/
|
|
private async handleArrowDown(e: KeyboardEvent, block: IBlock): Promise<void> {
|
|
// For non-editable blocks, always navigate to next block
|
|
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
|
if (nonEditableTypes.includes(block.type)) {
|
|
e.preventDefault();
|
|
const blockOps = this.component.blockOperations;
|
|
const nextBlock = blockOps.getNextBlock(block.id);
|
|
|
|
if (nextBlock) {
|
|
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
|
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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 we're on the last line
|
|
if (this.isOnLastLine(selectionInfo, target, ...shadowRoots)) {
|
|
e.preventDefault();
|
|
const blockOps = this.component.blockOperations;
|
|
const nextBlock = blockOps.getNextBlock(block.id);
|
|
|
|
if (nextBlock) {
|
|
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
|
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
|
|
}
|
|
}
|
|
// Otherwise, let browser handle normal navigation
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
// For non-editable blocks, navigate to previous block
|
|
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
|
if (nonEditableTypes.includes(block.type)) {
|
|
e.preventDefault();
|
|
const blockOps = this.component.blockOperations;
|
|
const prevBlock = blockOps.getPreviousBlock(block.id);
|
|
|
|
if (prevBlock) {
|
|
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
|
await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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);
|
|
|
|
if (cursorPos === 0) {
|
|
const blockOps = this.component.blockOperations;
|
|
const prevBlock = blockOps.getPreviousBlock(block.id);
|
|
|
|
if (prevBlock) {
|
|
e.preventDefault();
|
|
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
|
await blockOps.focusBlock(prevBlock.id, nonEditableTypes.includes(prevBlock.type) ? undefined : 'end');
|
|
}
|
|
}
|
|
// 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> {
|
|
// For non-editable blocks, navigate to next block
|
|
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
|
if (nonEditableTypes.includes(block.type)) {
|
|
e.preventDefault();
|
|
const blockOps = this.component.blockOperations;
|
|
const nextBlock = blockOps.getNextBlock(block.id);
|
|
|
|
if (nextBlock) {
|
|
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
|
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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 (nextBlock) {
|
|
e.preventDefault();
|
|
const nonEditableTypes = ['divider', 'image', 'youtube', 'markdown', 'html', 'attachment'];
|
|
await blockOps.focusBlock(nextBlock.id, nonEditableTypes.includes(nextBlock.type) ? undefined : 'start');
|
|
}
|
|
}
|
|
// Otherwise, let the browser handle normal right arrow navigation
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
} |