import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js'; import type { IBlock } from '../../wysiwyg.types.js'; import { cssManager } from '@design.estate/dees-element'; import { WysiwygBlocks } from '../../wysiwyg.blocks.js'; import { WysiwygSelection } from '../../wysiwyg.selection.js'; export class HeadingBlockHandler extends BaseBlockHandler { type: string; private level: 1 | 2 | 3; // Track cursor position private lastKnownCursorPosition: number = 0; private lastSelectedText: string = ''; private selectionHandler: (() => void) | null = null; constructor(type: 'heading-1' | 'heading-2' | 'heading-3') { super(); this.type = type; this.level = parseInt(type.split('-')[1]) as 1 | 2 | 3; } render(block: IBlock, isSelected: boolean): string { const selectedClass = isSelected ? ' selected' : ''; const placeholder = this.getPlaceholder(); console.log('HeadingBlockHandler.render:', { blockId: block.id, isSelected, content: block.content, level: this.level }); return `
`; } setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void { const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement; if (!headingBlock) { console.error('HeadingBlockHandler.setup: No heading block element found'); return; } console.log('HeadingBlockHandler.setup: Setting up heading block', { blockId: block.id, level: this.level }); // Set initial content if needed if (block.content && !headingBlock.innerHTML) { headingBlock.innerHTML = block.content; } // Input handler with cursor tracking headingBlock.addEventListener('input', (e) => { console.log('HeadingBlockHandler: Input event', { blockId: block.id }); handlers.onInput(e as InputEvent); // Track cursor position after input const pos = this.getCursorPosition(element); if (pos !== null) { this.lastKnownCursorPosition = pos; console.log('HeadingBlockHandler: Updated cursor position after input', { pos }); } }); // Keydown handler with cursor tracking headingBlock.addEventListener('keydown', (e) => { // Track cursor position before keydown const pos = this.getCursorPosition(element); if (pos !== null) { this.lastKnownCursorPosition = pos; console.log('HeadingBlockHandler: Cursor position before keydown', { pos, key: e.key }); } handlers.onKeyDown(e); }); // Focus handler headingBlock.addEventListener('focus', () => { console.log('HeadingBlockHandler: Focus event', { blockId: block.id }); handlers.onFocus(); }); // Blur handler headingBlock.addEventListener('blur', () => { console.log('HeadingBlockHandler: Blur event', { blockId: block.id }); handlers.onBlur(); }); // Composition handlers for IME support headingBlock.addEventListener('compositionstart', () => { console.log('HeadingBlockHandler: Composition start', { blockId: block.id }); handlers.onCompositionStart(); }); headingBlock.addEventListener('compositionend', () => { console.log('HeadingBlockHandler: Composition end', { blockId: block.id }); handlers.onCompositionEnd(); }); // Mouse up handler headingBlock.addEventListener('mouseup', (e) => { const pos = this.getCursorPosition(element); if (pos !== null) { this.lastKnownCursorPosition = pos; console.log('HeadingBlockHandler: Cursor position after mouseup', { pos }); } // Selection will be handled by selectionchange event handlers.onMouseUp?.(e); }); // Click handler with delayed cursor tracking headingBlock.addEventListener('click', (e: MouseEvent) => { // Small delay to let browser set cursor position setTimeout(() => { const pos = this.getCursorPosition(element); if (pos !== null) { this.lastKnownCursorPosition = pos; console.log('HeadingBlockHandler: Cursor position after click', { pos }); } }, 0); }); // Keyup handler for additional cursor tracking headingBlock.addEventListener('keyup', (e) => { const pos = this.getCursorPosition(element); if (pos !== null) { this.lastKnownCursorPosition = pos; console.log('HeadingBlockHandler: Cursor position after keyup', { pos, key: e.key }); } }); // Set up selection change handler this.setupSelectionHandler(element, headingBlock, block); } private setupSelectionHandler(element: HTMLElement, headingBlock: HTMLDivElement, block: IBlock): void { // Add selection change handler const checkSelection = () => { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return; const selectedText = selection.toString(); if (selectedText.length === 0) { // Clear selection if no text if (this.lastSelectedText) { this.lastSelectedText = ''; this.dispatchSelectionEvent(element, { text: '', blockId: block.id, hasSelection: false }); } return; } // Get parent wysiwyg component's shadow root - in setup, we need to traverse const wysiwygBlock = (headingBlock.getRootNode() as ShadowRoot).host as any; const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg'); const parentShadowRoot = parentComponent?.shadowRoot; const blockShadowRoot = wysiwygBlock?.shadowRoot; // Use getComposedRanges with shadow roots as per MDN docs const shadowRoots: ShadowRoot[] = []; if (parentShadowRoot) shadowRoots.push(parentShadowRoot); if (blockShadowRoot) shadowRoots.push(blockShadowRoot); // Get selection info using our Shadow DOM-aware utility const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots); if (!selectionInfo) return; // Check if selection is within this block const startInBlock = WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer); const endInBlock = WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.endContainer); if (startInBlock || endInBlock) { if (selectedText !== this.lastSelectedText) { this.lastSelectedText = selectedText; console.log('HeadingBlockHandler: Text selected', { text: selectedText, blockId: block.id }); // Create range and get rect const range = WysiwygSelection.createRangeFromInfo(selectionInfo); const rect = range.getBoundingClientRect(); // Dispatch event this.dispatchSelectionEvent(element, { text: selectedText.trim(), blockId: block.id, range: range, rect: rect, hasSelection: true }); } } else if (this.lastSelectedText) { // Clear selection if no longer in this block this.lastSelectedText = ''; this.dispatchSelectionEvent(element, { text: '', blockId: block.id, hasSelection: false }); } }; // Listen for selection changes document.addEventListener('selectionchange', checkSelection); // Store the handler for cleanup this.selectionHandler = checkSelection; // Clean up on disconnect (will be called by dees-wysiwyg-block) const wysiwygBlock = (headingBlock.getRootNode() as ShadowRoot).host as any; if (wysiwygBlock) { const originalDisconnectedCallback = (wysiwygBlock as any).disconnectedCallback; (wysiwygBlock as any).disconnectedCallback = async function() { if (this.selectionHandler) { document.removeEventListener('selectionchange', this.selectionHandler); this.selectionHandler = null; } if (originalDisconnectedCallback) { await originalDisconnectedCallback.call(wysiwygBlock); } }.bind(this); } } private dispatchSelectionEvent(element: HTMLElement, detail: any): void { const event = new CustomEvent('block-text-selected', { detail, bubbles: true, composed: true }); element.dispatchEvent(event); } getStyles(): string { // Return styles for all heading levels return ` .block.heading-1 { font-size: 32px; font-weight: 700; line-height: 1.2; margin: 24px 0 8px 0; color: ${cssManager.bdTheme('#000000', '#ffffff')}; } .block.heading-2 { font-size: 24px; font-weight: 600; line-height: 1.3; margin: 20px 0 6px 0; color: ${cssManager.bdTheme('#000000', '#ffffff')}; } .block.heading-3 { font-size: 20px; font-weight: 600; line-height: 1.4; margin: 16px 0 4px 0; color: ${cssManager.bdTheme('#000000', '#ffffff')}; } `; } getPlaceholder(): string { switch(this.level) { case 1: return 'Heading 1'; case 2: return 'Heading 2'; case 3: return 'Heading 3'; default: return 'Heading'; } } // Helper methods for heading functionality (mostly the same as paragraph) getCursorPosition(element: HTMLElement, context?: any): number | null { // Get the actual heading element const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement; if (!headingBlock) { console.log('HeadingBlockHandler.getCursorPosition: No heading element found'); return null; } // Get shadow roots from context const wysiwygBlock = context?.component; const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg'); const parentShadowRoot = parentComponent?.shadowRoot; const blockShadowRoot = context?.shadowRoot; // Get selection info with both shadow roots for proper traversal const shadowRoots: ShadowRoot[] = []; if (parentShadowRoot) shadowRoots.push(parentShadowRoot); if (blockShadowRoot) shadowRoots.push(blockShadowRoot); const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots); console.log('HeadingBlockHandler.getCursorPosition: Selection info from shadow DOMs:', { selectionInfo, shadowRootsCount: shadowRoots.length }); if (!selectionInfo) { console.log('HeadingBlockHandler.getCursorPosition: No selection found'); return null; } console.log('HeadingBlockHandler.getCursorPosition: Range info:', { startContainer: selectionInfo.startContainer, startOffset: selectionInfo.startOffset, collapsed: selectionInfo.collapsed, startContainerText: selectionInfo.startContainer.textContent }); if (!WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer)) { console.log('HeadingBlockHandler.getCursorPosition: Range not in element'); return null; } // Create a range from start of element to cursor position const preCaretRange = document.createRange(); preCaretRange.selectNodeContents(headingBlock); preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset); // Get the text content length up to cursor const position = preCaretRange.toString().length; console.log('HeadingBlockHandler.getCursorPosition: Calculated position:', { position, preCaretText: preCaretRange.toString(), elementText: headingBlock.textContent, elementTextLength: headingBlock.textContent?.length }); return position; } getContent(element: HTMLElement, context?: any): string { const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement; if (!headingBlock) return ''; // For headings, get the innerHTML which includes formatting tags const content = headingBlock.innerHTML || ''; console.log('HeadingBlockHandler.getContent:', content); return content; } setContent(element: HTMLElement, content: string, context?: any): void { const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement; if (!headingBlock) return; // Store if we have focus const hadFocus = document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock; headingBlock.innerHTML = content; // Restore focus if we had it if (hadFocus) { headingBlock.focus(); } } setCursorToStart(element: HTMLElement, context?: any): void { const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement; if (headingBlock) { WysiwygBlocks.setCursorToStart(headingBlock); } } setCursorToEnd(element: HTMLElement, context?: any): void { const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement; if (headingBlock) { WysiwygBlocks.setCursorToEnd(headingBlock); } } focus(element: HTMLElement, context?: any): void { const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement; if (!headingBlock) return; // Ensure the element is focusable if (!headingBlock.hasAttribute('contenteditable')) { headingBlock.setAttribute('contenteditable', 'true'); } headingBlock.focus(); // If focus failed, try again after a microtask if (document.activeElement !== headingBlock && element.shadowRoot?.activeElement !== headingBlock) { Promise.resolve().then(() => { headingBlock.focus(); }); } } focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end', context?: any): void { const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement; if (!headingBlock) return; // Ensure element is focusable first if (!headingBlock.hasAttribute('contenteditable')) { headingBlock.setAttribute('contenteditable', 'true'); } // Focus the element headingBlock.focus(); // Set cursor position after focus is established const setCursor = () => { if (position === 'start') { this.setCursorToStart(element, context); } else if (position === 'end') { this.setCursorToEnd(element, context); } else if (typeof position === 'number') { // Use the selection utility to set cursor position WysiwygSelection.setCursorPosition(headingBlock, position); } }; // Ensure cursor is set after focus if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) { setCursor(); } else { // Wait for focus to be established Promise.resolve().then(() => { if (document.activeElement === headingBlock || element.shadowRoot?.activeElement === headingBlock) { setCursor(); } }); } } getSplitContent(element: HTMLElement, context?: any): { before: string; after: string } | null { console.log('HeadingBlockHandler.getSplitContent: Starting...'); const headingBlock = element.querySelector(`.block.heading-${this.level}`) as HTMLDivElement; if (!headingBlock) { console.log('HeadingBlockHandler.getSplitContent: No heading element found'); return null; } console.log('HeadingBlockHandler.getSplitContent: Element info:', { innerHTML: headingBlock.innerHTML, textContent: headingBlock.textContent, textLength: headingBlock.textContent?.length }); // Get shadow roots from context const wysiwygBlock = context?.component; const parentComponent = wysiwygBlock?.closest('dees-input-wysiwyg'); const parentShadowRoot = parentComponent?.shadowRoot; const blockShadowRoot = context?.shadowRoot; // Get selection info with both shadow roots for proper traversal const shadowRoots: ShadowRoot[] = []; if (parentShadowRoot) shadowRoots.push(parentShadowRoot); if (blockShadowRoot) shadowRoots.push(blockShadowRoot); const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots); console.log('HeadingBlockHandler.getSplitContent: Selection info from shadow DOMs:', { selectionInfo, shadowRootsCount: shadowRoots.length }); if (!selectionInfo) { console.log('HeadingBlockHandler.getSplitContent: No selection, using last known position:', this.lastKnownCursorPosition); // Try using last known cursor position if (this.lastKnownCursorPosition !== null) { const fullText = headingBlock.textContent || ''; const pos = Math.min(this.lastKnownCursorPosition, fullText.length); console.log('HeadingBlockHandler.getSplitContent: Splitting with last known position:', { pos, fullTextLength: fullText.length, before: fullText.substring(0, pos), after: fullText.substring(pos) }); return { before: fullText.substring(0, pos), after: fullText.substring(pos) }; } return null; } console.log('HeadingBlockHandler.getSplitContent: Selection range:', { startContainer: selectionInfo.startContainer, startOffset: selectionInfo.startOffset, startContainerInElement: headingBlock.contains(selectionInfo.startContainer) }); // Make sure the selection is within this block if (!WysiwygSelection.containsAcrossShadowDOM(headingBlock, selectionInfo.startContainer)) { console.log('HeadingBlockHandler.getSplitContent: Selection not in this block, using last known position:', this.lastKnownCursorPosition); // Try using last known cursor position if (this.lastKnownCursorPosition !== null) { const fullText = headingBlock.textContent || ''; const pos = Math.min(this.lastKnownCursorPosition, fullText.length); return { before: fullText.substring(0, pos), after: fullText.substring(pos) }; } return null; } // Get cursor position first const cursorPos = this.getCursorPosition(element, context); console.log('HeadingBlockHandler.getSplitContent: Cursor position for HTML split:', cursorPos); if (cursorPos === null || cursorPos === 0) { // If cursor is at start or can't determine position, move all content console.log('HeadingBlockHandler.getSplitContent: Cursor at start or null, moving all content'); return { before: '', after: headingBlock.innerHTML }; } // For HTML content, split using ranges to preserve formatting const beforeRange = document.createRange(); const afterRange = document.createRange(); // Before range: from start of element to cursor beforeRange.setStart(headingBlock, 0); beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset); // After range: from cursor to end of element afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset); afterRange.setEnd(headingBlock, headingBlock.childNodes.length); // Extract HTML content const beforeFragment = beforeRange.cloneContents(); const afterFragment = afterRange.cloneContents(); // Convert to HTML strings const tempDiv = document.createElement('div'); tempDiv.appendChild(beforeFragment); const beforeHtml = tempDiv.innerHTML; tempDiv.innerHTML = ''; tempDiv.appendChild(afterFragment); const afterHtml = tempDiv.innerHTML; console.log('HeadingBlockHandler.getSplitContent: Final split result:', { cursorPos, beforeHtml, beforeLength: beforeHtml.length, beforeHtmlPreview: beforeHtml.substring(0, 100) + (beforeHtml.length > 100 ? '...' : ''), afterHtml, afterLength: afterHtml.length, afterHtmlPreview: afterHtml.substring(0, 100) + (afterHtml.length > 100 ? '...' : '') }); return { before: beforeHtml, after: afterHtml }; } }