import { customElement, property, static as html, DeesElement, type TemplateResult, cssManager, css, query, } from '@design.estate/dees-element'; import { type IBlock } from './wysiwyg.types.js'; import { WysiwygBlocks } from './wysiwyg.blocks.js'; import { WysiwygSelection } from './wysiwyg.selection.js'; declare global { interface HTMLElementTagNameMap { 'dees-wysiwyg-block': DeesWysiwygBlock; } } @customElement('dees-wysiwyg-block') export class DeesWysiwygBlock extends DeesElement { @property({ type: Object }) public block: IBlock; @property({ type: Boolean }) public isSelected: boolean = false; @property({ type: Object }) public handlers: { onInput: (e: InputEvent) => void; onKeyDown: (e: KeyboardEvent) => void; onFocus: () => void; onBlur: () => void; onCompositionStart: () => void; onCompositionEnd: () => void; onMouseUp?: (e: MouseEvent) => void; }; @query('.block') private blockElement: HTMLDivElement; // Track if we've initialized the content private contentInitialized: boolean = false; 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.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')}; } .block.quote { border-left: 3px solid ${cssManager.bdTheme('#0066cc', '#4d94ff')}; padding-left: 20px; color: ${cssManager.bdTheme('#555', '#b0b0b0')}; font-style: italic; line-height: 1.6; margin: 16px 0; } .block.code { font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; font-size: 14px; background: ${cssManager.bdTheme('#f8f8f8', '#0d0d0d')}; border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#2a2a2a')}; padding: 16px 20px; padding-top: 32px; border-radius: 6px; white-space: pre-wrap; color: ${cssManager.bdTheme('#24292e', '#e1e4e8')}; line-height: 1.5; overflow-x: auto; margin: 20px 0; } .block.list { padding: 0; } .block.list ul, .block.list ol { margin: 0; padding-left: 24px; } .block.list li { margin: 4px 0; } .block.divider { padding: 0; margin: 16px 0; pointer-events: none; } .block.divider hr { border: none; border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')}; margin: 0; } /* 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-language { position: absolute; top: 0; right: 0; background: ${cssManager.bdTheme('#e1e4e8', '#333333')}; color: ${cssManager.bdTheme('#586069', '#8b949e')}; padding: 4px 12px; font-size: 12px; border-radius: 0 6px 0 6px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; text-transform: lowercase; z-index: 1; } .code-block-container { position: relative; margin: 20px 0; } /* Selection styles */ .block ::selection { background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')}; color: inherit; } /* Paragraph specific styles */ .block.paragraph { font-size: 16px; line-height: 1.6; font-weight: 400; } /* Strike through */ .block :is(s, strike) { text-decoration: line-through; opacity: 0.7; } /* List specific margin adjustments */ .block.list li { margin-bottom: 8px; line-height: 1.6; } .block.list li:last-child { margin-bottom: 0; } /* 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 { // Never update if only the block content changed if (changedProperties.has('block') && this.block) { const oldBlock = changedProperties.get('block'); if (oldBlock && 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; // For code blocks, the actual contenteditable block is nested const editableBlock = this.block.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement : this.blockElement; // 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.logCursorPosition('input'); this.handlers?.onInput?.(e as InputEvent); }); editableBlock.addEventListener('keydown', (e) => { this.handlers?.onKeyDown?.(e); }); editableBlock.addEventListener('keyup', (e) => { this.logCursorPosition('keyup', 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) => { this.logCursorPosition('mouseup'); this.handleMouseUp(e); this.handlers?.onMouseUp?.(e); }); editableBlock.addEventListener('click', () => { this.logCursorPosition('click'); }); // Set initial content if needed if (this.block.content) { if (this.block.type === 'code') { editableBlock.textContent = this.block.content; } else if (this.block.type === 'list') { editableBlock.innerHTML = WysiwygBlocks.renderListContent(this.block.content, this.block.metadata); } else { editableBlock.innerHTML = this.block.content; } } } // Update blockElement reference for code blocks if (this.block.type === 'code') { this.blockElement = editableBlock; } } render(): TemplateResult { if (!this.block) return html``; if (this.block.type === 'divider') { return html`

`; } if (this.block.type === 'code') { const language = this.block.metadata?.language || 'plain text'; return html`
${language}
`; } const placeholder = this.getPlaceholder(); // Return static HTML without event bindings return html`
`; } private getPlaceholder(): string { switch (this.block.type) { case 'paragraph': return "Type '/' for commands..."; case 'heading-1': return 'Heading 1'; case 'heading-2': return 'Heading 2'; case 'heading-3': return 'Heading 3'; case 'quote': return 'Quote'; default: return ''; } } public focus(): void { if (!this.blockElement) return; // Ensure the element is focusable if (!this.blockElement.hasAttribute('contenteditable')) { this.blockElement.setAttribute('contenteditable', 'true'); } this.blockElement.focus(); // If focus failed, try again after a microtask if (document.activeElement !== this.blockElement) { Promise.resolve().then(() => { this.blockElement.focus(); }); } } public focusWithCursor(position: 'start' | 'end' | number = 'end'): void { if (!this.blockElement) return; // Ensure element is focusable first if (!this.blockElement.hasAttribute('contenteditable')) { this.blockElement.setAttribute('contenteditable', 'true'); } // Focus the element this.blockElement.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(this.blockElement, position); } }; // Ensure cursor is set after focus if (document.activeElement === this.blockElement) { setCursor(); } else { // Wait for focus to be established Promise.resolve().then(() => { if (document.activeElement === this.blockElement) { setCursor(); } }); } } private getFirstTextNode(node: Node): Text | null { if (node.nodeType === Node.TEXT_NODE) { return node as Text; } for (let i = 0; i < node.childNodes.length; i++) { const textNode = this.getFirstTextNode(node.childNodes[i]); if (textNode) return textNode; } return null; } public getContent(): string { if (!this.blockElement) return ''; if (this.block.type === 'list') { const listItems = this.blockElement.querySelectorAll('li'); return Array.from(listItems).map(li => li.innerHTML || '').join('\n'); } else if (this.block.type === 'code') { return this.blockElement.textContent || ''; } else { return this.blockElement.innerHTML || ''; } } public setContent(content: string): void { if (!this.blockElement) return; // Store if we have focus const hadFocus = document.activeElement === this.blockElement; if (this.block.type === 'list') { this.blockElement.innerHTML = WysiwygBlocks.renderListContent(content, this.block.metadata); } else if (this.block.type === 'code') { this.blockElement.textContent = content; } else { this.blockElement.innerHTML = content; } // Restore focus if we had it if (hadFocus) { this.blockElement.focus(); } } public setCursorToStart(): void { WysiwygBlocks.setCursorToStart(this.blockElement); } public setCursorToEnd(): void { WysiwygBlocks.setCursorToEnd(this.blockElement); } public focusListItem(): void { if (this.block.type === 'list') { WysiwygBlocks.focusListItem(this.blockElement); } } /** * Gets content split at cursor position */ public getSplitContent(): { before: string; after: string } | null { if (!this.blockElement) return null; // Get the full content first const fullContent = this.getContent(); console.log('getSplitContent: Full content:', { content: fullContent, length: fullContent.length, blockType: this.block.type }); // Get selection info using the new utility that handles Shadow DOM const selectionInfo = WysiwygSelection.getSelectionInfo(this.shadowRoot!); if (!selectionInfo) { console.log('getSplitContent: No selection, returning all content as before'); return { before: fullContent, after: '' }; } // Check if selection is within this block if (!WysiwygSelection.isSelectionInElement(this.blockElement, this.shadowRoot!)) { console.log('getSplitContent: Selection not in this block'); return null; } // Get cursor position as a number const cursorPosition = WysiwygSelection.getCursorPositionInElement(this.blockElement, this.shadowRoot!); console.log('getSplitContent: Cursor position:', { cursorPosition, contentLength: fullContent.length, startContainer: selectionInfo.startContainer, startOffset: selectionInfo.startOffset, collapsed: selectionInfo.collapsed }); // Handle special cases for different block types if (this.block.type === 'code') { // For code blocks, split text content const fullText = this.blockElement.textContent || ''; const textNode = this.getFirstTextNode(this.blockElement); if (textNode && selectionInfo.startContainer === textNode) { const before = fullText.substring(0, selectionInfo.startOffset); const after = fullText.substring(selectionInfo.startOffset); console.log('getSplitContent: Code block split result:', { cursorPosition, contentLength: fullText.length, beforeContent: before, beforeLength: before.length, afterContent: after, afterLength: after.length, startOffset: selectionInfo.startOffset }); return { before, after }; } } // For other block types, extract HTML content try { // Create a temporary range to get content before cursor const beforeRange = document.createRange(); beforeRange.selectNodeContents(this.blockElement); beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset); // Create a temporary range to get content after cursor const afterRange = document.createRange(); afterRange.selectNodeContents(this.blockElement); afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset); // Clone HTML content (not extract, to avoid modifying the DOM) const beforeContents = beforeRange.cloneContents(); const afterContents = afterRange.cloneContents(); // Convert to HTML strings const tempDiv = document.createElement('div'); tempDiv.appendChild(beforeContents); const beforeHtml = tempDiv.innerHTML; tempDiv.innerHTML = ''; tempDiv.appendChild(afterContents); const afterHtml = tempDiv.innerHTML; const result = { before: beforeHtml, after: afterHtml }; console.log('getSplitContent: Split result:', { cursorPosition, contentLength: fullContent.length, beforeContent: result.before, beforeLength: result.before.length, afterContent: result.after, afterLength: result.after.length }); return result; } catch (error) { console.error('Error splitting content:', error); // Fallback: return all content as "before" const fallbackResult = { before: this.getContent(), after: '' }; console.log('getSplitContent: Fallback result:', { beforeContent: fallbackResult.before, beforeLength: fallbackResult.before.length, afterContent: fallbackResult.after, afterLength: fallbackResult.after.length }); return fallbackResult; } } private handleMouseUp(_e: MouseEvent): void { // Check if we have a selection within this block setTimeout(() => { const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); // Check if selection is within this block if (this.blockElement && this.blockElement.contains(range.commonAncestorContainer)) { const selectedText = selection.toString(); if (selectedText.length > 0) { // Dispatch a custom event that can cross shadow DOM boundaries this.dispatchEvent(new CustomEvent('block-text-selected', { detail: { text: selectedText, blockId: this.block.id, range: range }, bubbles: true, composed: true })); } } } }, 10); } /** * Logs cursor position for debugging */ private logCursorPosition(eventType: string, event?: KeyboardEvent): void { console.log(`[CursorLog] Event triggered: ${eventType} in block ${this.block.id}`); // Get the actual active element considering shadow DOM const activeElement = this.shadowRoot?.activeElement; console.log(`[CursorLog] Active element:`, activeElement, 'Block element:', this.blockElement); // Only log if this block is focused if (activeElement !== this.blockElement) { console.log(`[CursorLog] Block not focused, skipping detailed logging`); return; } // Get selection info using the new utility that handles Shadow DOM const selectionInfo = WysiwygSelection.getSelectionInfo(this.shadowRoot!); if (!selectionInfo) { console.log(`[${eventType}] No selection available`); return; } const isInThisBlock = WysiwygSelection.isSelectionInElement(this.blockElement, this.shadowRoot!); if (!isInThisBlock) { return; } // Get cursor position details const details: any = { event: eventType, blockId: this.block.id, blockType: this.block.type, collapsed: selectionInfo.collapsed, startContainer: { nodeType: selectionInfo.startContainer.nodeType, nodeName: selectionInfo.startContainer.nodeName, textContent: selectionInfo.startContainer.textContent?.substring(0, 50) + '...', }, startOffset: selectionInfo.startOffset, }; // Add key info if it's a keyboard event if (event) { details.key = event.key; details.shiftKey = event.shiftKey; details.ctrlKey = event.ctrlKey; details.metaKey = event.metaKey; } // Try to get the actual cursor position in the text if (selectionInfo.startContainer.nodeType === Node.TEXT_NODE) { const textNode = selectionInfo.startContainer as Text; const textBefore = textNode.textContent?.substring(0, selectionInfo.startOffset) || ''; const textAfter = textNode.textContent?.substring(selectionInfo.startOffset) || ''; details.cursorPosition = { textBefore: textBefore.slice(-20), // Last 20 chars before cursor textAfter: textAfter.slice(0, 20), // First 20 chars after cursor totalLength: textNode.textContent?.length || 0, offset: selectionInfo.startOffset }; } // Check if we're at boundaries details.boundaries = { atStart: this.isCursorAtStart(selectionInfo), atEnd: this.isCursorAtEnd(selectionInfo) }; console.log('Cursor Position:', details); } /** * Check if cursor is at the start of the block */ private isCursorAtStart(selectionInfo: { startContainer: Node; startOffset: number; collapsed: boolean }): boolean { if (!selectionInfo.collapsed || selectionInfo.startOffset !== 0) return false; const firstNode = this.getFirstTextNode(this.blockElement); return !firstNode || selectionInfo.startContainer === firstNode || selectionInfo.startContainer === this.blockElement; } /** * Check if cursor is at the end of the block */ private isCursorAtEnd(selectionInfo: { endContainer: Node; endOffset: number; collapsed: boolean }): boolean { if (!selectionInfo.collapsed) return false; const lastNode = this.getLastTextNode(this.blockElement); if (!lastNode) return true; return selectionInfo.endContainer === lastNode && selectionInfo.endOffset === (lastNode.textContent?.length || 0); } /** * Get the last text node in the element */ private getLastTextNode(node: Node): Text | null { if (node.nodeType === Node.TEXT_NODE) { return node as Text; } for (let i = node.childNodes.length - 1; i >= 0; i--) { const lastText = this.getLastTextNode(node.childNodes[i]); if (lastText) return lastText; } return null; } }