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'; 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: { onInput: (e: InputEvent) => void; onKeyDown: (e: KeyboardEvent) => void; onFocus: () => void; onBlur: () => void; onCompositionStart: () => void; onCompositionEnd: () => void; onMouseUp?: (e: MouseEvent) => void; }; // 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 = ''; 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; // 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(); } // Now find the actual editable block element const editableBlock = this.block.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement : 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; console.log('Cursor position after mouseup:', pos); } // Selection will be handled by selectionchange event this.handlers?.onMouseUp?.(e); }); editableBlock.addEventListener('click', (e: MouseEvent) => { // Small delay to let browser set cursor position setTimeout(() => { const pos = this.getCursorPosition(editableBlock); if (pos !== null) { this.lastKnownCursorPosition = pos; console.log('Cursor position after click:', 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.block?.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement : 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 = currentEditableBlock.contains(selectionInfo.startContainer); const endInBlock = currentEditableBlock.contains(selectionInfo.endContainer); if (startInBlock || endInBlock) { if (selectedText !== this.lastSelectedText) { this.lastSelectedText = selectedText; console.log('✅ Selection detected in block using getComposedRanges:', 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', (e) => { // Track cursor position const pos = this.getCursorPosition(editableBlock); if (pos !== null) { this.lastKnownCursorPosition = pos; } }); // 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; } } } // 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 ''; if (this.block.type === 'divider') { return `

`; } if (this.block.type === 'code') { const language = this.block.metadata?.language || 'plain text'; const selectedClass = this.isSelected ? ' selected' : ''; return `
${language}
`; } const placeholder = this.getPlaceholder(); const selectedClass = this.isSelected ? ' selected' : ''; return `
`; } 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 { // Get the actual editable element (might be nested for code blocks) const editableElement = this.block?.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement : 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 { // Get the actual editable element (might be nested for code blocks) const editableElement = this.block?.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement : 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 { // 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 { // Get the actual editable element (might be nested for code blocks) const editableElement = this.block?.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement : this.shadowRoot?.querySelector('.block') as HTMLDivElement; if (!editableElement) return ''; if (this.block.type === 'list') { const listItems = editableElement.querySelectorAll('li'); return Array.from(listItems).map(li => li.innerHTML || '').join('\n'); } else if (this.block.type === 'code') { return editableElement.textContent || ''; } else { return editableElement.innerHTML || ''; } } public setContent(content: string): void { // Get the actual editable element (might be nested for code blocks) const editableElement = this.block?.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement : this.shadowRoot?.querySelector('.block') as HTMLDivElement; if (!editableElement) return; // Store if we have focus const hadFocus = document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement; if (this.block.type === 'list') { editableElement.innerHTML = WysiwygBlocks.renderListContent(content, this.block.metadata); } else if (this.block.type === 'code') { editableElement.textContent = content; } else { editableElement.innerHTML = content; } // Restore focus if we had it if (hadFocus) { editableElement.focus(); } } public setCursorToStart(): void { const editableElement = this.block?.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement : this.blockElement; if (editableElement) { WysiwygBlocks.setCursorToStart(editableElement); } } public setCursorToEnd(): void { const editableElement = this.block?.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement : this.blockElement; if (editableElement) { WysiwygBlocks.setCursorToEnd(editableElement); } } public focusListItem(): void { if (this.block.type === 'list') { const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement; if (editableElement) { WysiwygBlocks.focusListItem(editableElement); } } } /** * Gets content split at cursor position */ public getSplitContent(): { before: string; after: string } | null { console.log('getSplitContent: Starting...'); // Get the actual editable element first const editableElement = this.block?.type === 'code' ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement : 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 (!editableElement.contains(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 code blocks, use simple text splitting if (this.block.type === 'code') { const cursorPos = this.getCursorPosition(editableElement) || 0; const fullText = editableElement.textContent || ''; console.log('getSplitContent: Code block split:', { cursorPos, fullTextLength: fullText.length, before: fullText.substring(0, cursorPos), after: fullText.substring(cursorPos) }); return { before: fullText.substring(0, cursorPos), after: fullText.substring(cursorPos) }; } // 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 }; } }