import { html, type TemplateResult } from '@design.estate/dees-element'; import { WysiwygSelection } from './wysiwyg.selection.js'; export interface IFormatButton { command: string; icon: string; label: string; shortcut?: string; action?: () => void; } /** * Handles text formatting with smart toggle behavior: * - If selection contains ANY instance of a format, removes ALL instances * - If selection has no formatting, applies the format * - Works correctly with Shadow DOM using range-based operations */ 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, range?: Range, shadowRoots?: ShadowRoot[]): boolean { // If range is provided, use it directly (Shadow DOM case) // Otherwise fall back to window.getSelection() let workingRange: Range; if (range) { workingRange = range; } else { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; workingRange = selection.getRangeAt(0); } // Apply format based on command switch (command) { case 'bold': this.wrapSelection(workingRange, 'strong'); break; case 'italic': this.wrapSelection(workingRange, 'em'); break; case 'underline': this.wrapSelection(workingRange, 'u'); break; case 'strikeThrough': this.wrapSelection(workingRange, 's'); break; case 'code': this.wrapSelection(workingRange, 'code'); break; case 'link': // Don't use prompt - return false to indicate we need async input if (!value) { return false; } this.wrapSelectionWithLink(workingRange, value); break; } // If we have shadow roots, use our Shadow DOM selection utility if (shadowRoots && shadowRoots.length > 0) { WysiwygSelection.setSelectionFromRange(workingRange); } else { // Regular selection restoration const selection = window.getSelection(); if (selection) { selection.removeAllRanges(); selection.addRange(workingRange); } } return true; } private static wrapSelection(range: Range, tagName: string): void { const selection = window.getSelection(); if (!selection) return; // Check if ANY part of the selection contains this formatting const hasFormatting = this.selectionContainsTag(range, tagName); if (hasFormatting) { // Remove all instances of this tag from the selection this.removeTagFromSelection(range, tagName); } else { // Wrap selection with the tag const wrapper = document.createElement(tagName); try { // Extract and wrap contents const contents = range.extractContents(); wrapper.appendChild(contents); range.insertNode(wrapper); // Select the wrapped content range.selectNodeContents(wrapper); selection.removeAllRanges(); selection.addRange(range); } catch (e) { console.error('Failed to wrap selection:', e); } } } /** * Check if the selection contains or is within any instances of a tag */ private static selectionContainsTag(range: Range, tagName: string): boolean { // First check: Are we inside a tag? (even if selection doesn't include the tag) let node: Node | null = range.startContainer; while (node && node !== range.commonAncestorContainer.ownerDocument) { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element; if (element.tagName.toLowerCase() === tagName) { return true; } } node = node.parentNode; } // Also check the end container node = range.endContainer; while (node && node !== range.commonAncestorContainer.ownerDocument) { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element; if (element.tagName.toLowerCase() === tagName) { return true; } } node = node.parentNode; } // Second check: Does the selection contain any complete tags? const tempDiv = document.createElement('div'); const contents = range.cloneContents(); tempDiv.appendChild(contents); const tags = tempDiv.getElementsByTagName(tagName); return tags.length > 0; } /** * Remove all instances of a tag from the selection */ private static removeTagFromSelection(range: Range, tagName: string): void { const selection = window.getSelection(); if (!selection) return; // Special handling: Check if we need to expand the selection to include parent tags let expandedRange = range.cloneRange(); // Check if start is inside a tag let startNode: Node | null = range.startContainer; let startTag: Element | null = null; while (startNode && startNode !== range.commonAncestorContainer.ownerDocument) { if (startNode.nodeType === Node.ELEMENT_NODE && (startNode as Element).tagName.toLowerCase() === tagName) { startTag = startNode as Element; break; } startNode = startNode.parentNode; } // Check if end is inside a tag let endNode: Node | null = range.endContainer; let endTag: Element | null = null; while (endNode && endNode !== range.commonAncestorContainer.ownerDocument) { if (endNode.nodeType === Node.ELEMENT_NODE && (endNode as Element).tagName.toLowerCase() === tagName) { endTag = endNode as Element; break; } endNode = endNode.parentNode; } // Expand range to include the tags if needed if (startTag) { expandedRange.setStartBefore(startTag); } if (endTag) { expandedRange.setEndAfter(endTag); } // Extract the contents using the expanded range const fragment = expandedRange.extractContents(); // Process the fragment to remove tags const processedFragment = this.removeTagsFromFragment(fragment, tagName); // Insert the processed content back expandedRange.insertNode(processedFragment); // Restore selection to match the original selection intent // Find the text nodes that correspond to the original selection const textNodes: Node[] = []; const walker = document.createTreeWalker( processedFragment, NodeFilter.SHOW_TEXT, null ); let node; while (node = walker.nextNode()) { textNodes.push(node); } if (textNodes.length > 0) { const newRange = document.createRange(); newRange.setStart(textNodes[0], 0); newRange.setEnd(textNodes[textNodes.length - 1], textNodes[textNodes.length - 1].textContent?.length || 0); selection.removeAllRanges(); selection.addRange(newRange); } } /** * Remove all instances of a tag from a document fragment */ private static removeTagsFromFragment(fragment: DocumentFragment, tagName: string): DocumentFragment { const tempDiv = document.createElement('div'); tempDiv.appendChild(fragment); // Find all instances of the tag const tags = tempDiv.getElementsByTagName(tagName); // Convert to array to avoid live collection issues const tagArray = Array.from(tags); // Unwrap each tag tagArray.forEach(tag => { const parent = tag.parentNode; if (parent) { // Move all children out of the tag while (tag.firstChild) { parent.insertBefore(tag.firstChild, tag); } // Remove the empty tag parent.removeChild(tag); } }); // Create a new fragment from the processed content const newFragment = document.createDocumentFragment(); while (tempDiv.firstChild) { newFragment.appendChild(tempDiv.firstChild); } return newFragment; } private static wrapSelectionWithLink(range: Range, url: string): void { const selection = window.getSelection(); if (!selection) return; // First remove any existing links in the selection if (this.selectionContainsTag(range, 'a')) { this.removeTagFromSelection(range, 'a'); // Re-get the range after modification if (selection.rangeCount > 0) { range = selection.getRangeAt(0); } } const link = document.createElement('a'); link.href = url; link.target = '_blank'; link.rel = 'noopener noreferrer'; try { const contents = range.extractContents(); link.appendChild(contents); range.insertNode(link); // Select the link range.selectNodeContents(link); selection.removeAllRanges(); selection.addRange(range); } catch (e) { console.error('Failed to create link:', e); } } static getSelectionCoordinates(...shadowRoots: ShadowRoot[]): { x: number, y: number } | null { // Get selection info using the new utility that handles Shadow DOM const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots); console.log('getSelectionCoordinates - selectionInfo:', selectionInfo); if (!selectionInfo) { console.log('No selection info available'); return null; } // Create a range from the selection info to get bounding rect const range = WysiwygSelection.createRangeFromInfo(selectionInfo); const rect = range.getBoundingClientRect(); console.log('Range rect:', rect); if (rect.width === 0 && rect.height === 0) { console.log('Rect width and height are 0, trying different approach'); // Sometimes the rect is collapsed, let's try getting the caret position if ('caretPositionFromPoint' in document) { const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0); const tempSpan = document.createElement('span'); tempSpan.textContent = '\u200B'; // Zero-width space range.insertNode(tempSpan); const spanRect = tempSpan.getBoundingClientRect(); tempSpan.remove(); if (spanRect.width > 0 || spanRect.height > 0) { const coords = { x: spanRect.left, y: Math.max(45, spanRect.top - 45) }; console.log('Used span trick for coords:', coords); return coords; } } } 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; } }