import { customElement, property, html, DeesElement, type TemplateResult, cssManager, css, query, unsafeStatic, static as staticHtml, } from '@design.estate/dees-element'; import { type IBlock } from './wysiwyg.types.js'; import { WysiwygBlocks } from './wysiwyg.blocks.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; // Ensure the block element maintains its content if (this.blockElement) { this.blockElement.setAttribute('data-block-id', this.block.id); this.blockElement.setAttribute('data-block-type', this.block.type); } } 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(); const initialContent = this.getInitialContent(); return staticHtml`
${unsafeStatic(initialContent)}
`; } 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 ''; } } private getInitialContent(): string { if (this.block.type === 'list') { return WysiwygBlocks.renderListContent(this.block.content, this.block.metadata); } return this.block.content || ''; } 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 = () => { const selection = window.getSelection(); if (!selection) return; if (position === 'start') { this.setCursorToStart(); } else if (position === 'end') { this.setCursorToEnd(); } else if (typeof position === 'number') { // Set cursor at specific position const range = document.createRange(); const textNode = this.getFirstTextNode(this.blockElement); if (textNode) { const length = textNode.textContent?.length || 0; const safePosition = Math.min(position, length); range.setStart(textNode, safePosition); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } else if (this.blockElement.childNodes.length === 0) { // Empty block - create a text node const emptyText = document.createTextNode(''); this.blockElement.appendChild(emptyText); range.setStart(emptyText, 0); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } } }; // 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; const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) { return { before: this.getContent(), after: '' }; } const range = selection.getRangeAt(0); // Check if selection is within this block if (!this.blockElement.contains(range.commonAncestorContainer)) { return null; } // Clone the range to extract content before and after cursor const beforeRange = range.cloneRange(); beforeRange.selectNodeContents(this.blockElement); beforeRange.setEnd(range.startContainer, range.startOffset); const afterRange = range.cloneRange(); afterRange.selectNodeContents(this.blockElement); afterRange.setStart(range.endContainer, range.endOffset); // Extract content const beforeFragment = beforeRange.cloneContents(); const afterFragment = afterRange.cloneContents(); // Convert to HTML const tempDiv = document.createElement('div'); tempDiv.appendChild(beforeFragment); const beforeHtml = tempDiv.innerHTML; tempDiv.innerHTML = ''; tempDiv.appendChild(afterFragment); const afterHtml = tempDiv.innerHTML; return { before: beforeHtml, after: afterHtml }; } 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); } }