feat: Add WYSIWYG editor components and utilities
- Implemented WysiwygModalManager for managing modals related to code blocks and block settings. - Created WysiwygSelection for handling text selection across Shadow DOM boundaries. - Introduced WysiwygShortcuts for managing keyboard shortcuts and slash menu items. - Developed wysiwygStyles for consistent styling of the WYSIWYG editor. - Defined types for blocks, slash menu items, and shortcut patterns in wysiwyg.types.ts.
This commit is contained in:
770
ts_web/elements/dees-input-wysiwyg/wysiwyg.keyboardhandler.ts
Normal file
770
ts_web/elements/dees-input-wysiwyg/wysiwyg.keyboardhandler.ts
Normal file
@@ -0,0 +1,770 @@
|
||||
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 - handled by CodeBlockHandler
|
||||
// Let it bubble to the block handler
|
||||
return;
|
||||
} 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
|
||||
const nonEditableTypes = ['divider', 'image', 'youtube', 'attachment'];
|
||||
if (nonEditableTypes.includes(block.type)) {
|
||||
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();
|
||||
|
||||
// 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}"]`);
|
||||
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
|
||||
|
||||
if (blockComponent && blockComponent.getSplitContent) {
|
||||
const splitContent = blockComponent.getSplitContent();
|
||||
|
||||
if (splitContent) {
|
||||
// Update current block with content before cursor
|
||||
blockComponent.setContent(splitContent.before);
|
||||
block.content = splitContent.before;
|
||||
|
||||
// Create new block with content after cursor
|
||||
const newBlock = blockOps.createBlock('paragraph', splitContent.after);
|
||||
|
||||
// Insert the new block
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
|
||||
// Update the value after both blocks are set
|
||||
this.component.updateValue();
|
||||
} else {
|
||||
// Fallback - just create empty block
|
||||
const newBlock = blockOps.createBlock();
|
||||
await blockOps.insertBlockAfter(block, newBlock);
|
||||
}
|
||||
} else {
|
||||
// No block component or method, just create 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', '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('.code-editor') 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 actualContent = blockComponent.getContent ? blockComponent.getContent() : target.textContent;
|
||||
|
||||
// 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', '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
|
||||
const actualContent = blockComponent.getContent ? blockComponent.getContent() : block.content;
|
||||
if (actualContent === '' || actualContent.trim() === '') {
|
||||
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 (this.component.blocks.length > 1) {
|
||||
// Check if block is actually empty by getting current content from DOM
|
||||
const currentContent = blockComponent.getContent ? blockComponent.getContent() : block.content;
|
||||
|
||||
if (currentContent === '' || currentContent.trim() === '') {
|
||||
// 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', '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', '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('.code-editor') 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', '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', '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 now use .code-editor
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.code-editor') 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', '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', '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', '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 now use .code-editor
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.code-editor') 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', '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', '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', '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 now use .code-editor
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.code-editor') 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', 'attachment'];
|
||||
const position = nonEditableTypes.includes(prevBlock.type) ? undefined : 'end';
|
||||
await blockOps.focusBlock(prevBlock.id, position);
|
||||
}
|
||||
}
|
||||
// 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', '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', '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 now use .code-editor
|
||||
const target = block.type === 'code'
|
||||
? blockComponent.shadowRoot.querySelector('.code-editor') 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', '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;
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user