diff --git a/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts index 8119b44..6023602 100644 --- a/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts +++ b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts @@ -17,7 +17,8 @@ import { WysiwygConverters, WysiwygShortcuts, WysiwygBlocks, - type ISlashMenuItem + type ISlashMenuItem, + WysiwygFormatting } from './index.js'; declare global { @@ -69,9 +70,19 @@ export class DeesInputWysiwyg extends DeesInputBase { @state() private dragOverPosition: 'before' | 'after' | null = null; + @state() + private showFormattingMenu: boolean = false; + + @state() + private formattingMenuPosition: { x: number; y: number } = { x: 0, y: 0 }; + + @state() + private selectedText: string = ''; + private editorContentRef: HTMLDivElement; private isComposing: boolean = false; private saveTimeout: any = null; + private selectionChangeHandler = () => this.handleSelectionChange(); public static styles = [ ...DeesInputBase.baseStyles, @@ -79,9 +90,24 @@ export class DeesInputWysiwyg extends DeesInputBase { wysiwygStyles ]; + async connectedCallback() { + await super.connectedCallback(); + } + + async disconnectedCallback() { + await super.disconnectedCallback(); + // Remove selection listener + document.removeEventListener('selectionchange', this.selectionChangeHandler); + } + async firstUpdated() { this.updateValue(); this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement; + + // Add global selection listener + console.log('Adding selectionchange listener'); + document.addEventListener('selectionchange', this.selectionChangeHandler); + // Set initial content for blocks after a brief delay to ensure DOM is ready await this.updateComplete; setTimeout(() => { @@ -116,6 +142,9 @@ export class DeesInputWysiwyg extends DeesInputBase { if (isEmpty && block.content) { if (block.type === 'list') { blockElement.innerHTML = WysiwygBlocks.renderListContent(block.content, block.metadata); + } else if (block.content.includes('<') && block.content.includes('>')) { + // Content contains HTML formatting + blockElement.innerHTML = block.content; } else { blockElement.textContent = block.content; } @@ -140,6 +169,7 @@ export class DeesInputWysiwyg extends DeesInputBase { ${this.blocks.map(block => this.renderBlock(block))} ${this.showSlashMenu ? this.renderSlashMenu() : ''} + ${this.showFormattingMenu ? this.renderFormattingMenu() : ''} `; } @@ -172,6 +202,7 @@ export class DeesInputWysiwyg extends DeesInputBase { onBlur: () => this.handleBlockBlur(block), onCompositionStart: () => this.isComposing = true, onCompositionEnd: () => this.isComposing = false, + onMouseUp: (e: MouseEvent) => this.handleTextSelection(e), })} `; @@ -207,6 +238,13 @@ export class DeesInputWysiwyg extends DeesInputBase { `; } + private renderFormattingMenu(): TemplateResult { + return WysiwygFormatting.renderFormattingMenu( + this.formattingMenuPosition, + (command) => this.applyFormat(command) + ); + } + private handleBlockInput(e: InputEvent, block: IBlock) { if (this.isComposing) return; @@ -327,6 +365,28 @@ export class DeesInputWysiwyg extends DeesInputBase { return; } + // Handle formatting shortcuts + if (e.metaKey || e.ctrlKey) { + switch (e.key.toLowerCase()) { + case 'b': + e.preventDefault(); + this.applyFormat('bold'); + return; + case 'i': + e.preventDefault(); + this.applyFormat('italic'); + return; + case 'u': + e.preventDefault(); + this.applyFormat('underline'); + return; + case 'k': + e.preventDefault(); + this.applyFormat('link'); + return; + } + } + // Handle Tab key for indentation if (e.key === 'Tab') { if (block.type === 'code') { @@ -756,4 +816,172 @@ export class DeesInputWysiwyg extends DeesInputBase { } }, 100); } + + + private handleTextSelection(e: MouseEvent): void { + // Stop event to prevent it from bubbling up + e.stopPropagation(); + + console.log('handleTextSelection called from mouseup on contenteditable'); + + // Small delay to ensure selection is complete + setTimeout(() => { + // Alternative approach: check selection directly within the target element + const target = e.target as HTMLElement; + const selection = window.getSelection(); + + if (selection && selection.rangeCount > 0) { + const selectedText = selection.toString(); + console.log('Direct selection check in handleTextSelection:', { + selectedText: selectedText.substring(0, 50), + hasText: selectedText.length > 0, + target: target.tagName + '.' + target.className + }); + + if (selectedText.length > 0) { + // We know this came from a mouseup on our contenteditable, so it's definitely our selection + console.log('✅ Text selected via mouseup:', selectedText); + this.selectedText = selectedText; + this.updateFormattingMenuPosition(); + } else if (this.showFormattingMenu) { + this.hideFormattingMenu(); + } + } + }, 50); + } + + private handleSelectionChange(): void { + // Try to get selection from shadow root first, then fall back to window + const shadowSelection = (this.shadowRoot as any).getSelection ? (this.shadowRoot as any).getSelection() : null; + const windowSelection = window.getSelection(); + const editorContent = this.shadowRoot?.querySelector('.editor-content') as HTMLElement; + + // Check both shadow and window selections + let selection = shadowSelection; + let selectedText = shadowSelection?.toString() || ''; + + // If no shadow selection, check window selection + if (!selectedText && windowSelection) { + selection = windowSelection; + selectedText = windowSelection.toString() || ''; + } + + console.log('Selection change:', { + hasText: selectedText.length > 0, + selectedText: selectedText.substring(0, 50), + shadowSelection: !!shadowSelection, + windowSelection: !!windowSelection, + rangeCount: selection?.rangeCount, + editorContent: !!editorContent + }); + + if (!selection || selection.rangeCount === 0 || !editorContent) { + console.log('No selection or editor content'); + return; + } + + // If we have selected text, show the formatting menu + if (selectedText.length > 0) { + console.log('✅ Text selected:', selectedText); + + if (selectedText !== this.selectedText) { + this.selectedText = selectedText; + this.updateFormattingMenuPosition(); + } + } else if (this.showFormattingMenu) { + console.log('No text selected, hiding menu'); + this.hideFormattingMenu(); + } + } + + private getRootNodeOfNode(node: Node): Node { + let current: Node = node; + while (current.parentNode) { + current = current.parentNode; + } + return current; + } + + private updateFormattingMenuPosition(): void { + console.log('updateFormattingMenuPosition called'); + const coords = WysiwygFormatting.getSelectionCoordinates(this.shadowRoot as ShadowRoot); + console.log('Selection coordinates:', coords); + + if (coords) { + const container = this.shadowRoot!.querySelector('.wysiwyg-container'); + if (!container) { + console.error('Container not found!'); + return; + } + + const containerRect = container.getBoundingClientRect(); + this.formattingMenuPosition = { + x: coords.x - containerRect.left, + y: coords.y - containerRect.top + }; + + console.log('Setting menu position:', this.formattingMenuPosition); + this.showFormattingMenu = true; + console.log('showFormattingMenu set to:', this.showFormattingMenu); + + // Force update + this.requestUpdate(); + + // Check if menu exists in DOM after update + setTimeout(() => { + const menu = this.shadowRoot?.querySelector('.formatting-menu'); + console.log('Menu in DOM after update:', menu); + if (menu) { + console.log('Menu style:', menu.getAttribute('style')); + } + }, 100); + } else { + console.log('No coordinates found'); + } + } + + private hideFormattingMenu(): void { + this.showFormattingMenu = false; + this.selectedText = ''; + } + + private applyFormat(command: string): void { + // Save current selection before applying format + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + + // Get the current block to update its content + const anchorNode = selection.anchorNode; + const blockElement = anchorNode?.nodeType === Node.TEXT_NODE + ? anchorNode.parentElement?.closest('.block') + : (anchorNode as Element)?.closest('.block'); + + if (!blockElement) return; + + const blockId = blockElement.closest('.block-wrapper')?.getAttribute('data-block-id'); + const block = this.blocks.find(b => b.id === blockId); + + if (!block) return; + + // Apply the format + WysiwygFormatting.applyFormat(command); + + // Update block content after format is applied + setTimeout(() => { + if (block.type === 'list') { + const listItems = blockElement.querySelectorAll('li'); + block.content = Array.from(listItems).map(li => li.textContent || '').join('\n'); + } else { + // For other blocks, preserve HTML formatting + block.content = blockElement.innerHTML; + } + + this.updateValue(); + + // Keep selection active + if (command !== 'link') { + this.updateFormattingMenuPosition(); + } + }, 10); + } } \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/index.ts b/ts_web/elements/wysiwyg/index.ts index 087411d..7ddd84e 100644 --- a/ts_web/elements/wysiwyg/index.ts +++ b/ts_web/elements/wysiwyg/index.ts @@ -2,4 +2,5 @@ export * from './wysiwyg.types.js'; export * from './wysiwyg.styles.js'; export * from './wysiwyg.converters.js'; export * from './wysiwyg.shortcuts.js'; -export * from './wysiwyg.blocks.js'; \ No newline at end of file +export * from './wysiwyg.blocks.js'; +export * from './wysiwyg.formatting.js'; \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/wysiwyg.blocks.ts b/ts_web/elements/wysiwyg/wysiwyg.blocks.ts index 68b1c94..0f5f972 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.blocks.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.blocks.ts @@ -20,6 +20,7 @@ export class WysiwygBlocks { onBlur: () => void; onCompositionStart: () => void; onCompositionEnd: () => void; + onMouseUp?: (e: MouseEvent) => void; } ): TemplateResult { if (block.type === 'divider') { @@ -45,6 +46,10 @@ export class WysiwygBlocks { @blur="${handlers.onBlur}" @compositionstart="${handlers.onCompositionStart}" @compositionend="${handlers.onCompositionEnd}" + @mouseup="${(e: MouseEvent) => { + console.log('Block mouseup event fired'); + if (handlers.onMouseUp) handlers.onMouseUp(e); + }}" .innerHTML="${this.renderListContent(block.content, block.metadata)}" > `; @@ -60,6 +65,10 @@ export class WysiwygBlocks { @blur="${handlers.onBlur}" @compositionstart="${handlers.onCompositionStart}" @compositionend="${handlers.onCompositionEnd}" + @mouseup="${(e: MouseEvent) => { + console.log('Block mouseup event fired'); + if (handlers.onMouseUp) handlers.onMouseUp(e); + }}" > `; } diff --git a/ts_web/elements/wysiwyg/wysiwyg.converters.ts b/ts_web/elements/wysiwyg/wysiwyg.converters.ts index 542ec39..023e4c0 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.converters.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.converters.ts @@ -9,17 +9,22 @@ export class WysiwygConverters { static getHtmlOutput(blocks: IBlock[]): string { return blocks.map(block => { + // Check if content already contains HTML formatting + const content = block.content.includes('<') && block.content.includes('>') + ? block.content // Already contains HTML formatting + : this.escapeHtml(block.content); + switch (block.type) { case 'paragraph': - return block.content ? `

${this.escapeHtml(block.content)}

` : ''; + return block.content ? `

${content}

` : ''; case 'heading-1': - return `

${this.escapeHtml(block.content)}

`; + return `

${content}

`; case 'heading-2': - return `

${this.escapeHtml(block.content)}

`; + return `

${content}

`; case 'heading-3': - return `

${this.escapeHtml(block.content)}

`; + return `

${content}

`; case 'quote': - return `
${this.escapeHtml(block.content)}
`; + return `
${content}
`; case 'code': return `
${this.escapeHtml(block.content)}
`; case 'list': @@ -32,7 +37,7 @@ export class WysiwygConverters { case 'divider': return '
'; default: - return `

${this.escapeHtml(block.content)}

`; + return `

${content}

`; } }).filter(html => html !== '').join('\n'); } @@ -88,35 +93,35 @@ export class WysiwygConverters { blocks.push({ id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, type: 'paragraph', - content: element.textContent || '', + content: element.innerHTML || '', }); break; case 'h1': blocks.push({ id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, type: 'heading-1', - content: element.textContent || '', + content: element.innerHTML || '', }); break; case 'h2': blocks.push({ id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, type: 'heading-2', - content: element.textContent || '', + content: element.innerHTML || '', }); break; case 'h3': blocks.push({ id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, type: 'heading-3', - content: element.textContent || '', + content: element.innerHTML || '', }); break; case 'blockquote': blocks.push({ id: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, type: 'quote', - content: element.textContent || '', + content: element.innerHTML || '', }); break; case 'pre': diff --git a/ts_web/elements/wysiwyg/wysiwyg.formatting.ts b/ts_web/elements/wysiwyg/wysiwyg.formatting.ts new file mode 100644 index 0000000..ebc6478 --- /dev/null +++ b/ts_web/elements/wysiwyg/wysiwyg.formatting.ts @@ -0,0 +1,149 @@ +import { html, type TemplateResult } from '@design.estate/dees-element'; + +export interface IFormatButton { + command: string; + icon: string; + label: string; + shortcut?: string; + action?: () => void; +} + +export class WysiwygFormatting { + static readonly formatButtons: IFormatButton[] = [ + { command: 'bold', icon: 'B', label: 'Bold', shortcut: '⌘B' }, + { command: 'italic', icon: 'I', label: 'Italic', shortcut: '⌘I' }, + { command: 'underline', icon: 'U', label: 'Underline', shortcut: '⌘U' }, + { command: 'strikeThrough', icon: 'S̶', label: 'Strikethrough' }, + { command: 'code', icon: '{ }', label: 'Inline Code' }, + { command: 'link', icon: '🔗', label: 'Link', shortcut: '⌘K' }, + ]; + + static renderFormattingMenu( + position: { x: number; y: number }, + onFormat: (command: string) => void + ): TemplateResult { + return html` +
+ ${this.formatButtons.map(button => html` + + `)} +
+ `; + } + + static applyFormat(command: string, value?: string): void { + // Save current selection + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + + const range = selection.getRangeAt(0); + + // Apply format based on command + switch (command) { + case 'bold': + case 'italic': + case 'underline': + case 'strikeThrough': + document.execCommand(command, false); + break; + + case 'code': + // For inline code, wrap selection in tags + const codeElement = document.createElement('code'); + try { + codeElement.appendChild(range.extractContents()); + range.insertNode(codeElement); + + // Select the newly created code element + range.selectNodeContents(codeElement); + selection.removeAllRanges(); + selection.addRange(range); + } catch (e) { + // Fallback to execCommand if range manipulation fails + document.execCommand('fontName', false, 'monospace'); + } + break; + + case 'link': + const url = value || prompt('Enter URL:'); + if (url) { + document.execCommand('createLink', false, url); + } + break; + } + } + + static getSelectionCoordinates(shadowRoot?: ShadowRoot): { x: number, y: number } | null { + // Try shadow root selection first, then window + let selection = shadowRoot && (shadowRoot as any).getSelection ? (shadowRoot as any).getSelection() : null; + if (!selection || selection.rangeCount === 0) { + selection = window.getSelection(); + } + + console.log('getSelectionCoordinates - selection:', selection); + + if (!selection || selection.rangeCount === 0) { + console.log('No selection or no ranges'); + return null; + } + + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + console.log('Range rect:', rect); + + if (rect.width === 0) { + console.log('Rect width is 0'); + return null; + } + + const coords = { + x: rect.left + (rect.width / 2), + y: Math.max(45, rect.top - 45) // Position above selection, but ensure it's not negative + }; + + console.log('Returning coords:', coords); + return coords; + } + + static isFormattingApplied(command: string): boolean { + try { + return document.queryCommandState(command); + } catch { + return false; + } + } + + static hasSelection(): boolean { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + console.log('No selection or no ranges'); + return false; + } + + // Check if we actually have selected text (not just collapsed cursor) + const selectedText = selection.toString(); + if (!selectedText || selectedText.length === 0) { + console.log('No text selected'); + return false; + } + + return true; + } + + static getSelectedText(): string { + const selection = window.getSelection(); + return selection ? selection.toString() : ''; + } +} \ No newline at end of file diff --git a/ts_web/elements/wysiwyg/wysiwyg.styles.ts b/ts_web/elements/wysiwyg/wysiwyg.styles.ts index 0724bfa..f0e9e86 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.styles.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.styles.ts @@ -26,6 +26,11 @@ export const wysiwygStyles = css` box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0, 102, 204, 0.1)', 'rgba(77, 148, 255, 0.1)')}; } + /* Visual hint for text selection */ + .editor-content:hover { + cursor: text; + } + .editor-content { outline: none; min-height: 160px; @@ -388,4 +393,120 @@ export const wysiwygStyles = css` .editor-content.dragging * { user-select: none; } + + /* Text Selection Styles */ + .block ::selection { + background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.3)', 'rgba(77, 148, 255, 0.3)')}; + color: inherit; + } + + /* Formatting Menu */ + .formatting-menu { + position: absolute; + background: ${cssManager.bdTheme('#ffffff', '#262626')}; + border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#404040')}; + border-radius: 6px; + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.15); + padding: 4px; + display: flex; + gap: 2px; + z-index: 1001; + animation: fadeInScale 0.15s ease-out; + } + + @keyframes fadeInScale { + from { + opacity: 0; + transform: scale(0.95) translateY(5px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } + } + + .format-button { + width: 32px; + height: 32px; + border: none; + background: transparent; + cursor: pointer; + border-radius: 4px; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + color: ${cssManager.bdTheme('#000000', '#e0e0e0')}; + font-weight: 600; + font-size: 14px; + position: relative; + } + + .format-button:hover { + background: ${cssManager.bdTheme('#f0f0f0', '#333333')}; + color: ${cssManager.bdTheme('#0066cc', '#4d94ff')}; + } + + .format-button:active { + transform: scale(0.95); + } + + .format-button.bold { + font-weight: 700; + } + + .format-button.italic { + font-style: italic; + } + + .format-button.underline { + text-decoration: underline; + } + + .format-button .code-icon { + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; + font-size: 12px; + } + + /* Applied format styles in content */ + .block strong, + .block b { + font-weight: 600; + color: ${cssManager.bdTheme('#000000', '#ffffff')}; + } + + .block em, + .block i { + font-style: italic; + } + + .block u { + text-decoration: underline; + } + + .block strike, + .block s { + text-decoration: line-through; + opacity: 0.7; + } + + .block code { + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.1)')}; + padding: 2px 6px; + border-radius: 3px; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; + font-size: 0.9em; + 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; + } + + .block a:hover { + border-bottom-color: ${cssManager.bdTheme('#0066cc', '#4d94ff')}; + } `; \ No newline at end of file