From 90fc8bed35fc87dcf323bee6da51377b1908f7d6 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 24 Jun 2025 17:09:19 +0000 Subject: [PATCH] update selection reversal --- ts_web/elements/wysiwyg/dees-input-wysiwyg.ts | 103 +------- ts_web/elements/wysiwyg/wysiwyg.formatting.ts | 236 ++++++++++++++++-- 2 files changed, 218 insertions(+), 121 deletions(-) diff --git a/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts index e508a01..b45d306 100644 --- a/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts +++ b/ts_web/elements/wysiwyg/dees-input-wysiwyg.ts @@ -817,14 +817,17 @@ export class DeesInputWysiwyg extends DeesInputBase { targetBlockComponent.focus(); return; } - // Apply link format with Shadow DOM aware formatting - this.applyFormatInShadowDOM(range, command, targetBlockComponent, url); + // Apply link format + WysiwygFormatting.applyFormat(command, url, range, shadowRoots); } else { - // Apply the format with Shadow DOM aware formatting - this.applyFormatInShadowDOM(range, command, targetBlockComponent); + // Apply the format + WysiwygFormatting.applyFormat(command, undefined, range, shadowRoots); } - // Update content immediately + // Update content after a microtask to ensure DOM is updated + await new Promise(resolve => setTimeout(resolve, 10)); + + // Force content update targetBlock.content = targetBlockComponent.getContent(); // Update value to persist changes @@ -970,96 +973,6 @@ export class DeesInputWysiwyg extends DeesInputBase { /** * Save current state to history with cursor position */ - /** - * Apply formatting within Shadow DOM context - */ - private applyFormatInShadowDOM(range: Range, command: string, blockComponent: any, value?: string): void { - const editableElement = blockComponent.shadowRoot?.querySelector('.block') as HTMLElement; - if (!editableElement) return; - - // Apply format based on command - switch (command) { - case 'bold': - this.wrapSelectionInShadowDOM(range, 'strong', editableElement); - break; - - case 'italic': - this.wrapSelectionInShadowDOM(range, 'em', editableElement); - break; - - case 'underline': - this.wrapSelectionInShadowDOM(range, 'u', editableElement); - break; - - case 'strikeThrough': - this.wrapSelectionInShadowDOM(range, 's', editableElement); - break; - - case 'code': - this.wrapSelectionInShadowDOM(range, 'code', editableElement); - break; - - case 'link': - if (value) { - this.wrapSelectionWithLinkInShadowDOM(range, value, editableElement); - } - break; - } - } - - /** - * Wrap selection with a tag within Shadow DOM - */ - private wrapSelectionInShadowDOM(range: Range, tagName: string, editableElement: HTMLElement): void { - try { - // Check if we're already wrapped in this tag - const parentElement = range.commonAncestorContainer.parentElement; - if (parentElement && parentElement.tagName.toLowerCase() === tagName) { - // Unwrap - const parent = parentElement.parentNode; - while (parentElement.firstChild) { - parent?.insertBefore(parentElement.firstChild, parentElement); - } - parent?.removeChild(parentElement); - } else { - // Wrap selection - const wrapper = editableElement.ownerDocument.createElement(tagName); - const contents = range.extractContents(); - wrapper.appendChild(contents); - range.insertNode(wrapper); - - // Select the wrapped content - range.selectNodeContents(wrapper); - - // Update selection using our Shadow DOM utilities - WysiwygSelection.setSelectionFromRange(range); - } - } catch (e) { - console.error('Failed to apply format:', e); - } - } - - /** - * Wrap selection with a link within Shadow DOM - */ - private wrapSelectionWithLinkInShadowDOM(range: Range, url: string, editableElement: HTMLElement): void { - try { - const link = editableElement.ownerDocument.createElement('a'); - link.href = url; - link.target = '_blank'; - link.rel = 'noopener noreferrer'; - - const contents = range.extractContents(); - link.appendChild(contents); - range.insertNode(link); - - // Select the link - range.selectNodeContents(link); - WysiwygSelection.setSelectionFromRange(range); - } catch (e) { - console.error('Failed to create link:', e); - } - } public saveToHistory(debounce: boolean = true): void { // Get current cursor position if a block is focused diff --git a/ts_web/elements/wysiwyg/wysiwyg.formatting.ts b/ts_web/elements/wysiwyg/wysiwyg.formatting.ts index 7aad5d6..5e4b80a 100644 --- a/ts_web/elements/wysiwyg/wysiwyg.formatting.ts +++ b/ts_web/elements/wysiwyg/wysiwyg.formatting.ts @@ -9,6 +9,12 @@ export interface IFormatButton { 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' }, @@ -43,33 +49,39 @@ export class WysiwygFormatting { `; } - static applyFormat(command: string, value?: string): boolean { - // Save current selection - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return false; - - const range = selection.getRangeAt(0); + 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(range, 'strong'); + this.wrapSelection(workingRange, 'strong'); break; case 'italic': - this.wrapSelection(range, 'em'); + this.wrapSelection(workingRange, 'em'); break; case 'underline': - this.wrapSelection(range, 'u'); + this.wrapSelection(workingRange, 'u'); break; case 'strikeThrough': - this.wrapSelection(range, 's'); + this.wrapSelection(workingRange, 's'); break; case 'code': - this.wrapSelection(range, 'code'); + this.wrapSelection(workingRange, 'code'); break; case 'link': @@ -77,10 +89,22 @@ export class WysiwygFormatting { if (!value) { return false; } - this.wrapSelectionWithLink(range, value); + 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; } @@ -88,21 +112,17 @@ export class WysiwygFormatting { const selection = window.getSelection(); if (!selection) return; - // Check if we're already wrapped in this tag - const parentElement = range.commonAncestorContainer.parentElement; - if (parentElement && parentElement.tagName.toLowerCase() === tagName) { - // Unwrap - const parent = parentElement.parentNode; - while (parentElement.firstChild) { - parent?.insertBefore(parentElement.firstChild, parentElement); - } - parent?.removeChild(parentElement); - - // Restore selection - selection.removeAllRanges(); - selection.addRange(range); + // Check if ANY part of the selection contains this formatting + const hasFormatting = this.selectionContainsTag(range, tagName); + console.log(`Formatting check for <${tagName}>: ${hasFormatting ? 'HAS formatting' : 'NO formatting'}`); + + if (hasFormatting) { + console.log(`Removing <${tagName}> formatting from selection`); + // Remove all instances of this tag from the selection + this.removeTagFromSelection(range, tagName); } else { - // Wrap selection + console.log(`Adding <${tagName}> formatting to selection`); + // Wrap selection with the tag const wrapper = document.createElement(tagName); try { // Extract and wrap contents @@ -120,10 +140,174 @@ export class WysiwygFormatting { } } + /** + * Check if the selection contains or is within any instances of a tag + */ + private static selectionContainsTag(range: Range, tagName: string): boolean { + console.log(`Checking if selection contains <${tagName}>...`); + + // First check: Are we inside a tag? (even if selection doesn't include the tag) + let node: Node | null = range.startContainer; + console.log('Start container:', range.startContainer, 'type:', range.startContainer.nodeType); + + while (node && node !== range.commonAncestorContainer.ownerDocument) { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element; + console.log(` Checking parent element: <${element.tagName.toLowerCase()}>`); + if (element.tagName.toLowerCase() === tagName) { + console.log(` ✓ Found <${tagName}> as parent of start container`); + return true; + } + } + node = node.parentNode; + } + + // Also check the end container + node = range.endContainer; + console.log('End container:', range.endContainer, 'type:', range.endContainer.nodeType); + + while (node && node !== range.commonAncestorContainer.ownerDocument) { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element; + console.log(` Checking parent element: <${element.tagName.toLowerCase()}>`); + if (element.tagName.toLowerCase() === tagName) { + console.log(` ✓ Found <${tagName}> as parent of end container`); + 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); + console.log(` Selection contains ${tags.length} complete <${tagName}> tags`); + + 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';