Files
dees-catalog/ts_web/elements/wysiwyg/wysiwyg.inputhandler.ts
2025-06-24 13:41:12 +00:00

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);
}
}
}