import { customElement, property, static as html, DeesElement, type TemplateResult, cssManager, css, } from '@design.estate/dees-element'; import { type IBlock } from './wysiwyg.types.js'; import { WysiwygBlocks } from './wysiwyg.blocks.js'; import { WysiwygSelection } from './wysiwyg.selection.js'; import { BlockRegistry, type IBlockEventHandlers } from './blocks/index.js'; import './wysiwyg.blockregistration.js'; declare global { interface HTMLElementTagNameMap { 'dees-wysiwyg-block': DeesWysiwygBlock; } } @customElement('dees-wysiwyg-block') export class DeesWysiwygBlock extends DeesElement { async disconnectedCallback() { await super.disconnectedCallback(); // Clean up selection handler if ((this as any)._selectionHandler) { document.removeEventListener('selectionchange', (this as any)._selectionHandler); } } @property({ type: Object }) public block: IBlock; @property({ type: Boolean }) public isSelected: boolean = false; @property({ type: Object }) public handlers: IBlockEventHandlers; // Reference to the editable block element private blockElement: HTMLDivElement | null = null; // Track if we've initialized the content private contentInitialized: boolean = false; // Track cursor position private lastKnownCursorPosition: number = 0; private lastSelectedText: string = ''; private handlerStylesInjected = false; // Block types that don't support contenteditable private static readonly NON_EDITABLE_TYPES = ['image', 'divider', 'youtube']; private injectHandlerStyles(): void { // Only inject once per instance if (this.handlerStylesInjected) return; this.handlerStylesInjected = true; // Get styles from all registered block handlers let styles = ''; const blockTypes = BlockRegistry.getAllTypes(); for (const type of blockTypes) { const handler = BlockRegistry.getHandler(type); if (handler) { styles += handler.getStyles(); } } if (styles) { // Create and inject style element const styleElement = document.createElement('style'); styleElement.textContent = styles; this.shadowRoot?.appendChild(styleElement); } } public static styles = [ cssManager.defaultStyles, css` :host { display: block; } .block { padding: 4px 0; min-height: 1.6em; outline: none; width: 100%; word-wrap: break-word; position: relative; transition: all 0.15s ease; color: ${cssManager.bdTheme('#000000', '#e0e0e0')}; } .block:empty:not(:focus)::before { content: attr(data-placeholder); color: ${cssManager.bdTheme('#999', '#666')}; position: absolute; pointer-events: none; } /* Block-specific styles moved to handlers */ /* Formatting styles */ .block :is(b, strong) { font-weight: 600; color: ${cssManager.bdTheme('#000000', '#ffffff')}; } .block :is(i, em) { font-style: italic; } .block u { text-decoration: underline; } .block s { text-decoration: line-through; } .block code { font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; font-size: 0.9em; background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.1)')}; padding: 2px 6px; border-radius: 3px; color: ${cssManager.bdTheme('#d14', '#ff6b6b')}; } .block a { color: ${cssManager.bdTheme('#0066cc', '#4d94ff')}; text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.15s ease; cursor: pointer; } .block a:hover { border-bottom-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')}; } /* Code block container and language styles moved to handler */ /* Selection styles */ .block ::selection { background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')}; color: inherit; } /* Strike through */ .block :is(s, strike) { text-decoration: line-through; opacity: 0.7; } /* Block margin adjustments based on type */ :host-context(.block-wrapper:first-child) .block { margin-top: 0 !important; } :host-context(.block-wrapper:last-child) .block { margin-bottom: 0; } /* Selected state */ .block.selected { background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.05)', 'rgba(77, 148, 255, 0.08)')}; box-shadow: inset 0 0 0 2px ${cssManager.bdTheme('rgba(0, 102, 204, 0.2)', 'rgba(77, 148, 255, 0.2)')}; border-radius: 4px; margin-left: -8px; margin-right: -8px; padding-left: 8px; padding-right: 8px; } `, ]; protected shouldUpdate(changedProperties: Map): boolean { // If selection state changed, update the selected class without re-rendering if (changedProperties.has('isSelected') && this.block) { // Find the block element based on block type let element: HTMLElement | null = null; // Build the specific selector based on block type const blockType = this.block.type; const selector = `.block.${blockType}`; element = this.shadowRoot?.querySelector(selector) as HTMLElement; if (element) { if (this.isSelected) { element.classList.add('selected'); } else { element.classList.remove('selected'); } } return false; // Don't re-render, just update the class } // Never update if only the block content changed if (changedProperties.has('block') && this.block) { const oldBlock = changedProperties.get('block'); if (oldBlock && oldBlock.id && oldBlock.type && oldBlock.id === this.block.id && oldBlock.type === this.block.type) { // Only content or metadata changed, don't re-render return false; } } // Only update if the block type or id changes return !this.blockElement || this.block?.type !== this.blockElement.dataset.blockType; } public firstUpdated(): void { // Mark that content has been initialized this.contentInitialized = true; // Inject handler styles if not already done this.injectHandlerStyles(); // First, populate the container with the rendered content const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement; if (container && this.block) { container.innerHTML = this.renderBlockContent(); } // Check if we have a registered handler for this block type if (this.block) { const handler = BlockRegistry.getHandler(this.block.type); if (handler) { const blockElement = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement; if (blockElement) { handler.setup(blockElement, this.block, this.handlers); } return; // Block handler takes care of all setup } } // Handle special block types // Now find the actual editable block element const editableBlock = this.shadowRoot?.querySelector('.block') as HTMLDivElement; // Ensure the block element maintains its content if (editableBlock) { editableBlock.setAttribute('data-block-id', this.block.id); editableBlock.setAttribute('data-block-type', this.block.type); // Set up all event handlers manually to avoid Lit re-renders editableBlock.addEventListener('input', (e) => { this.handlers?.onInput?.(e as InputEvent); // Track cursor position after input const pos = this.getCursorPosition(editableBlock); if (pos !== null) { this.lastKnownCursorPosition = pos; } }); editableBlock.addEventListener('keydown', (e) => { // Track cursor position before keydown const pos = this.getCursorPosition(editableBlock); if (pos !== null) { this.lastKnownCursorPosition = pos; } this.handlers?.onKeyDown?.(e); }); editableBlock.addEventListener('focus', () => { this.handlers?.onFocus?.(); }); editableBlock.addEventListener('blur', () => { this.handlers?.onBlur?.(); }); editableBlock.addEventListener('compositionstart', () => { this.handlers?.onCompositionStart?.(); }); editableBlock.addEventListener('compositionend', () => { this.handlers?.onCompositionEnd?.(); }); editableBlock.addEventListener('mouseup', (e) => { const pos = this.getCursorPosition(editableBlock); if (pos !== null) { this.lastKnownCursorPosition = pos; } // Selection will be handled by selectionchange event this.handlers?.onMouseUp?.(e); }); editableBlock.addEventListener('click', () => { // Small delay to let browser set cursor position setTimeout(() => { const pos = this.getCursorPosition(editableBlock); if (pos !== null) { this.lastKnownCursorPosition = pos; } }, 0); }); // 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.dispatchEvent(new CustomEvent('block-text-selected', { detail: { text: '', blockId: this.block.id, hasSelection: false }, bubbles: true, composed: true })); } return; } // Get fresh reference to the editable block const currentEditableBlock = this.shadowRoot?.querySelector('.block') as HTMLDivElement; if (!currentEditableBlock) return; // Get parent wysiwyg component's shadow root const parentComponent = this.closest('dees-input-wysiwyg'); const parentShadowRoot = parentComponent?.shadowRoot; // Use getComposedRanges with shadow roots as per MDN docs const shadowRoots: ShadowRoot[] = []; if (parentShadowRoot) shadowRoots.push(parentShadowRoot); if (this.shadowRoot) shadowRoots.push(this.shadowRoot); // 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(currentEditableBlock, selectionInfo.startContainer); const endInBlock = WysiwygSelection.containsAcrossShadowDOM(currentEditableBlock, selectionInfo.endContainer); if (startInBlock || endInBlock) { if (selectedText !== this.lastSelectedText) { this.lastSelectedText = selectedText; // Create range and get rect const range = WysiwygSelection.createRangeFromInfo(selectionInfo); const rect = range.getBoundingClientRect(); // Dispatch event this.dispatchEvent(new CustomEvent('block-text-selected', { detail: { text: selectedText.trim(), blockId: this.block.id, range: range, rect: rect, hasSelection: true }, bubbles: true, composed: true })); } } else if (this.lastSelectedText) { // Clear selection if no longer in this block this.lastSelectedText = ''; this.dispatchEvent(new CustomEvent('block-text-selected', { detail: { text: '', blockId: this.block.id, hasSelection: false }, bubbles: true, composed: true })); } }; // Listen for selection changes document.addEventListener('selectionchange', checkSelection); // Store the handler for cleanup (this as any)._selectionHandler = checkSelection; // Add keyup handler for cursor position tracking editableBlock.addEventListener('keyup', () => { // Track cursor position const pos = this.getCursorPosition(editableBlock); if (pos !== null) { this.lastKnownCursorPosition = pos; } }); // Set initial content if needed if (this.block.content) { editableBlock.innerHTML = this.block.content; } } // Store reference to the block element for quick access this.blockElement = editableBlock; } render(): TemplateResult { if (!this.block) return html``; // Since we need dynamic content, we'll render an empty container // and set the innerHTML in firstUpdated return html`
`; } private renderBlockContent(): string { if (!this.block) return ''; // Check if we have a registered handler for this block type const handler = BlockRegistry.getHandler(this.block.type); if (handler) { return handler.render(this.block, this.isSelected); } // Default rendering for blocks without handlers const selectedClass = this.isSelected ? ' selected' : ''; return `
`; } public focus(): void { // Check if we have a registered handler for this block type const handler = BlockRegistry.getHandler(this.block.type); if (handler && handler.focus) { const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement; const context = { shadowRoot: this.shadowRoot!, component: this }; return handler.focus(container, context); } // Handle non-editable blocks if (this.block && DeesWysiwygBlock.NON_EDITABLE_TYPES.includes(this.block.type)) { const blockElement = this.shadowRoot?.querySelector(`.block.${this.block.type}`) as HTMLDivElement; if (blockElement) { blockElement.focus(); } return; } // Get the actual editable element const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement; if (!editableElement) return; // Ensure the element is focusable if (!editableElement.hasAttribute('contenteditable')) { editableElement.setAttribute('contenteditable', 'true'); } editableElement.focus(); // If focus failed, try again after a microtask if (document.activeElement !== editableElement && this.shadowRoot?.activeElement !== editableElement) { Promise.resolve().then(() => { editableElement.focus(); }); } } public focusWithCursor(position: 'start' | 'end' | number = 'end'): void { // Check if we have a registered handler for this block type const handler = BlockRegistry.getHandler(this.block.type); if (handler && handler.focusWithCursor) { const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement; const context = { shadowRoot: this.shadowRoot!, component: this }; return handler.focusWithCursor(container, position, context); } // Non-editable blocks don't support cursor positioning if (this.block && DeesWysiwygBlock.NON_EDITABLE_TYPES.includes(this.block.type)) { this.focus(); return; } // Get the actual editable element const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement; if (!editableElement) return; // Ensure element is focusable first if (!editableElement.hasAttribute('contenteditable')) { editableElement.setAttribute('contenteditable', 'true'); } // Focus the element editableElement.focus(); // Set cursor position after focus is established const setCursor = () => { if (position === 'start') { this.setCursorToStart(); } else if (position === 'end') { this.setCursorToEnd(); } else if (typeof position === 'number') { // Use the new selection utility to set cursor position WysiwygSelection.setCursorPosition(editableElement, position); } }; // Ensure cursor is set after focus if (document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement) { setCursor(); } else { // Wait for focus to be established Promise.resolve().then(() => { if (document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement) { setCursor(); } }); } } /** * Get cursor position in the editable element */ public getCursorPosition(element: HTMLElement): number | null { // Check if we have a registered handler for this block type const handler = BlockRegistry.getHandler(this.block.type); if (handler && handler.getCursorPosition) { const context = { shadowRoot: this.shadowRoot!, component: this }; return handler.getCursorPosition(element, context); } // Get parent wysiwyg component's shadow root const parentComponent = this.closest('dees-input-wysiwyg'); const parentShadowRoot = parentComponent?.shadowRoot; // Get selection info with both shadow roots for proper traversal const shadowRoots: ShadowRoot[] = []; if (parentShadowRoot) shadowRoots.push(parentShadowRoot); if (this.shadowRoot) shadowRoots.push(this.shadowRoot); const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots); console.log('getCursorPosition: Selection info from shadow DOMs:', { selectionInfo, shadowRootsCount: shadowRoots.length }); if (!selectionInfo) { console.log('getCursorPosition: No selection found'); return null; } console.log('getCursorPosition: Range info:', { startContainer: selectionInfo.startContainer, startOffset: selectionInfo.startOffset, collapsed: selectionInfo.collapsed, startContainerText: selectionInfo.startContainer.textContent }); if (!element.contains(selectionInfo.startContainer)) { console.log('getCursorPosition: Range not in element'); return null; } // Create a range from start of element to cursor position const preCaretRange = document.createRange(); preCaretRange.selectNodeContents(element); preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset); // Get the text content length up to cursor const position = preCaretRange.toString().length; console.log('getCursorPosition: Calculated position:', { position, preCaretText: preCaretRange.toString(), elementText: element.textContent, elementTextLength: element.textContent?.length }); return position; } public getContent(): string { // Check if we have a registered handler for this block type const handler = BlockRegistry.getHandler(this.block.type); if (handler && handler.getContent) { const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement; const context = { shadowRoot: this.shadowRoot!, component: this }; return handler.getContent(container, context); } // Get the actual editable element const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement; if (!editableElement) return ''; // Get the innerHTML which includes formatting tags const content = editableElement.innerHTML || ''; console.log('Getting content from block:', content); return content; } public setContent(content: string): void { // Check if we have a registered handler for this block type const handler = BlockRegistry.getHandler(this.block.type); if (handler && handler.setContent) { const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement; const context = { shadowRoot: this.shadowRoot!, component: this }; return handler.setContent(container, content, context); } // Get the actual editable element const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement; if (!editableElement) return; // Store if we have focus const hadFocus = document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement; editableElement.innerHTML = content; // Restore focus if we had it if (hadFocus) { editableElement.focus(); } } public setCursorToStart(): void { // Check if we have a registered handler for this block type const handler = BlockRegistry.getHandler(this.block.type); if (handler && handler.setCursorToStart) { const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement; const context = { shadowRoot: this.shadowRoot!, component: this }; return handler.setCursorToStart(container, context); } // Always find the element fresh, don't rely on cached blockElement const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement; if (editableElement) { WysiwygBlocks.setCursorToStart(editableElement); } } public setCursorToEnd(): void { // Check if we have a registered handler for this block type const handler = BlockRegistry.getHandler(this.block.type); if (handler && handler.setCursorToEnd) { const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement; const context = { shadowRoot: this.shadowRoot!, component: this }; return handler.setCursorToEnd(container, context); } // Always find the element fresh, don't rely on cached blockElement const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement; if (editableElement) { WysiwygBlocks.setCursorToEnd(editableElement); } } /** * Gets content split at cursor position */ public getSplitContent(): { before: string; after: string } | null { console.log('getSplitContent: Starting...'); // Check if we have a registered handler for this block type const handler = BlockRegistry.getHandler(this.block.type); console.log('getSplitContent: Checking for handler', { blockType: this.block.type, hasHandler: !!handler, hasSplitMethod: !!(handler && handler.getSplitContent) }); if (handler && handler.getSplitContent) { const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLElement; console.log('getSplitContent: Found container', { container: !!container, containerHTML: container?.innerHTML?.substring(0, 100) }); const context = { shadowRoot: this.shadowRoot!, component: this }; return handler.getSplitContent(container, context); } // Get the actual editable element first const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement; if (!editableElement) { console.log('getSplitContent: No editable element found'); return null; } console.log('getSplitContent: Element info:', { blockType: this.block.type, innerHTML: editableElement.innerHTML, textContent: editableElement.textContent, textLength: editableElement.textContent?.length }); // Get parent wysiwyg component's shadow root const parentComponent = this.closest('dees-input-wysiwyg'); const parentShadowRoot = parentComponent?.shadowRoot; // Get selection info with both shadow roots for proper traversal const shadowRoots: ShadowRoot[] = []; if (parentShadowRoot) shadowRoots.push(parentShadowRoot); if (this.shadowRoot) shadowRoots.push(this.shadowRoot); const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots); console.log('getSplitContent: Selection info from shadow DOMs:', { selectionInfo, shadowRootsCount: shadowRoots.length }); if (!selectionInfo) { console.log('getSplitContent: No selection, using last known position:', this.lastKnownCursorPosition); // Try using last known cursor position if (this.lastKnownCursorPosition !== null) { const fullText = editableElement.textContent || ''; const pos = Math.min(this.lastKnownCursorPosition, fullText.length); console.log('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('getSplitContent: Selection range:', { startContainer: selectionInfo.startContainer, startOffset: selectionInfo.startOffset, startContainerInElement: editableElement.contains(selectionInfo.startContainer) }); // Make sure the selection is within this block if (!WysiwygSelection.containsAcrossShadowDOM(editableElement, selectionInfo.startContainer)) { console.log('getSplitContent: Selection not in this block, using last known position:', this.lastKnownCursorPosition); // Try using last known cursor position if (this.lastKnownCursorPosition !== null) { const fullText = editableElement.textContent || ''; const pos = Math.min(this.lastKnownCursorPosition, fullText.length); return { before: fullText.substring(0, pos), after: fullText.substring(pos) }; } return null; } // For HTML content, get cursor position first const cursorPos = this.getCursorPosition(editableElement); console.log('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('getSplitContent: Cursor at start or null, moving all content'); return { before: '', after: editableElement.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(editableElement, 0); beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset); // After range: from cursor to end of element afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset); afterRange.setEnd(editableElement, editableElement.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('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 }; } }