302 lines
9.3 KiB
TypeScript
302 lines
9.3 KiB
TypeScript
import { type IBlock } from './wysiwyg.types.js';
|
|
import { type IWysiwygComponent } from './wysiwyg.interfaces.js';
|
|
import { WysiwygShortcuts } from './wysiwyg.shortcuts.js';
|
|
import { WysiwygBlocks } from './wysiwyg.blocks.js';
|
|
import { WysiwygBlockOperations } from './wysiwyg.blockoperations.js';
|
|
import { WysiwygModalManager } from './wysiwyg.modalmanager.js';
|
|
|
|
export class WysiwygInputHandler {
|
|
private component: IWysiwygComponent;
|
|
private saveTimeout: any = null;
|
|
|
|
constructor(component: IWysiwygComponent) {
|
|
this.component = component;
|
|
}
|
|
|
|
/**
|
|
* Handles input events for blocks
|
|
*/
|
|
handleBlockInput(e: InputEvent, block: IBlock): void {
|
|
if (this.component.isComposing) return;
|
|
|
|
const target = e.target as HTMLDivElement;
|
|
const textContent = target.textContent || '';
|
|
|
|
// Check for block type transformations BEFORE updating content
|
|
const detectedType = this.detectBlockTypeIntent(textContent);
|
|
if (detectedType && detectedType.type !== block.type) {
|
|
e.preventDefault();
|
|
this.handleBlockTransformation(block, detectedType, target);
|
|
return;
|
|
}
|
|
|
|
// Handle slash commands
|
|
this.handleSlashCommand(textContent, target);
|
|
|
|
// Don't update block content immediately - let the block handle its own content
|
|
// This prevents re-renders during typing
|
|
|
|
// Schedule auto-save (which will sync content later)
|
|
this.scheduleAutoSave();
|
|
}
|
|
|
|
/**
|
|
* Updates block content based on its type
|
|
*/
|
|
private updateBlockContent(block: IBlock, target: HTMLDivElement): void {
|
|
// Get the block component for proper content extraction
|
|
const wrapperElement = target.closest('.block-wrapper');
|
|
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
|
|
|
|
if (blockComponent) {
|
|
// Use the block component's getContent method for consistency
|
|
const newContent = blockComponent.getContent();
|
|
// Only update if content actually changed to avoid unnecessary updates
|
|
if (block.content !== newContent) {
|
|
block.content = newContent;
|
|
}
|
|
|
|
// Update list metadata if needed
|
|
if (block.type === 'list') {
|
|
const listElement = target.querySelector('ol, ul');
|
|
if (listElement) {
|
|
block.metadata = {
|
|
listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet'
|
|
};
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback if block component not found
|
|
if (block.type === 'list') {
|
|
const listItems = target.querySelectorAll('li');
|
|
// Use innerHTML to preserve formatting
|
|
block.content = Array.from(listItems).map(li => li.innerHTML || '').join('\n');
|
|
|
|
const listElement = target.querySelector('ol, ul');
|
|
if (listElement) {
|
|
block.metadata = {
|
|
listType: listElement.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet'
|
|
};
|
|
}
|
|
} else if (block.type === 'code') {
|
|
block.content = target.textContent || '';
|
|
} else {
|
|
block.content = target.innerHTML || '';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detects if the user is trying to create a specific block type
|
|
*/
|
|
private detectBlockTypeIntent(content: string): { type: IBlock['type'], listType?: 'bullet' | 'ordered' } | null {
|
|
// Check heading patterns
|
|
const headingResult = WysiwygShortcuts.checkHeadingShortcut(content);
|
|
if (headingResult) {
|
|
return headingResult;
|
|
}
|
|
|
|
// Check list patterns
|
|
const listResult = WysiwygShortcuts.checkListShortcut(content);
|
|
if (listResult) {
|
|
return listResult;
|
|
}
|
|
|
|
// Check quote pattern
|
|
if (WysiwygShortcuts.checkQuoteShortcut(content)) {
|
|
return { type: 'quote' };
|
|
}
|
|
|
|
// Check code pattern
|
|
if (WysiwygShortcuts.checkCodeShortcut(content)) {
|
|
return { type: 'code' };
|
|
}
|
|
|
|
// Check divider pattern
|
|
if (WysiwygShortcuts.checkDividerShortcut(content)) {
|
|
return { type: 'divider' };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Handles block type transformation
|
|
*/
|
|
private async handleBlockTransformation(
|
|
block: IBlock,
|
|
detectedType: { type: IBlock['type'], listType?: 'bullet' | 'ordered' },
|
|
target: HTMLDivElement
|
|
): Promise<void> {
|
|
const blockOps = this.component.blockOperations;
|
|
|
|
if (detectedType.type === 'list') {
|
|
block.type = 'list';
|
|
block.content = '';
|
|
block.metadata = { listType: detectedType.listType };
|
|
|
|
const listTag = detectedType.listType === 'ordered' ? 'ol' : 'ul';
|
|
target.innerHTML = `<${listTag}><li></li></${listTag}>`;
|
|
|
|
this.component.updateValue();
|
|
|
|
// Update the block element programmatically
|
|
if (this.component.editorContentRef) {
|
|
this.component.updateBlockElement(block.id);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
WysiwygBlocks.focusListItem(target);
|
|
}, 0);
|
|
} else if (detectedType.type === 'divider') {
|
|
block.type = 'divider';
|
|
block.content = ' ';
|
|
|
|
// Update the block element programmatically
|
|
if (this.component.editorContentRef) {
|
|
this.component.updateBlockElement(block.id);
|
|
}
|
|
|
|
const newBlock = blockOps.createBlock();
|
|
blockOps.insertBlockAfter(block, newBlock);
|
|
|
|
this.component.updateValue();
|
|
} else if (detectedType.type === 'code') {
|
|
const language = await WysiwygModalManager.showLanguageSelectionModal();
|
|
if (language) {
|
|
block.type = 'code';
|
|
block.content = '';
|
|
block.metadata = { language };
|
|
target.textContent = '';
|
|
|
|
this.component.updateValue();
|
|
|
|
// Update the block element programmatically
|
|
if (this.component.editorContentRef) {
|
|
this.component.updateBlockElement(block.id);
|
|
}
|
|
|
|
// Focus the code block
|
|
setTimeout(async () => {
|
|
await blockOps.focusBlock(block.id, 'start');
|
|
}, 50);
|
|
}
|
|
} else {
|
|
block.type = detectedType.type;
|
|
block.content = '';
|
|
target.textContent = '';
|
|
|
|
this.component.updateValue();
|
|
|
|
// Update the block element programmatically
|
|
if (this.component.editorContentRef) {
|
|
this.component.updateBlockElement(block.id);
|
|
}
|
|
|
|
// Focus the transformed block
|
|
setTimeout(async () => {
|
|
await blockOps.focusBlock(block.id, 'start');
|
|
}, 50);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles slash command detection and menu display
|
|
*/
|
|
private handleSlashCommand(textContent: string, target: HTMLDivElement): void {
|
|
const slashMenu = this.component.slashMenu;
|
|
const isSlashMenuVisible = slashMenu && slashMenu.visible;
|
|
|
|
if (textContent === '/' || (textContent.startsWith('/') && isSlashMenuVisible)) {
|
|
if (!isSlashMenuVisible && textContent === '/') {
|
|
// Get position for menu based on cursor location
|
|
const rect = this.getCaretCoordinates(target);
|
|
|
|
// Show the slash menu at the cursor position
|
|
slashMenu.show(
|
|
{ x: rect.left, y: rect.bottom + 4 },
|
|
(type: string) => {
|
|
this.component.insertBlock(type);
|
|
}
|
|
);
|
|
|
|
// Ensure the block maintains focus
|
|
requestAnimationFrame(() => {
|
|
if (document.activeElement !== target) {
|
|
target.focus();
|
|
}
|
|
});
|
|
}
|
|
// Update filter
|
|
if (slashMenu) {
|
|
slashMenu.updateFilter(textContent.slice(1));
|
|
}
|
|
} else if (!textContent.startsWith('/')) {
|
|
this.component.closeSlashMenu();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the coordinates of the caret/cursor
|
|
*/
|
|
private getCaretCoordinates(element: HTMLElement): DOMRect {
|
|
const selection = window.getSelection();
|
|
if (selection && selection.rangeCount > 0) {
|
|
const range = selection.getRangeAt(0);
|
|
const rect = range.getBoundingClientRect();
|
|
if (rect.width > 0 || rect.height > 0) {
|
|
return rect;
|
|
}
|
|
}
|
|
|
|
// Fallback to element position
|
|
return element.getBoundingClientRect();
|
|
}
|
|
|
|
/**
|
|
* Schedules auto-save after a delay
|
|
*/
|
|
private scheduleAutoSave(): void {
|
|
if (this.saveTimeout) {
|
|
clearTimeout(this.saveTimeout);
|
|
}
|
|
// Don't auto-save if slash menu is open
|
|
if (this.component.slashMenu && this.component.slashMenu.visible) {
|
|
return;
|
|
}
|
|
this.saveTimeout = setTimeout(() => {
|
|
// Sync all block content from DOM before saving
|
|
this.syncAllBlockContent();
|
|
// Only update value, don't trigger any re-renders
|
|
this.component.updateValue();
|
|
// Don't call requestUpdate() as it's not needed
|
|
}, 2000); // Increased delay to reduce interference with typing
|
|
}
|
|
|
|
/**
|
|
* Syncs content from all block DOMs to the data model
|
|
*/
|
|
private syncAllBlockContent(): void {
|
|
this.component.blocks.forEach((block: IBlock) => {
|
|
const wrapperElement = this.component.shadowRoot?.querySelector(`[data-block-id="${block.id}"]`);
|
|
const blockComponent = wrapperElement?.querySelector('dees-wysiwyg-block') as any;
|
|
|
|
if (blockComponent && blockComponent.getContent) {
|
|
const newContent = blockComponent.getContent();
|
|
// Only update if content actually changed
|
|
if (block.content !== newContent) {
|
|
block.content = newContent;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Cleans up resources
|
|
*/
|
|
destroy(): void {
|
|
if (this.saveTimeout) {
|
|
clearTimeout(this.saveTimeout);
|
|
}
|
|
}
|
|
} |