update selection reversal
This commit is contained in:
@ -817,14 +817,17 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
||||
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<string> {
|
||||
/**
|
||||
* 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
|
||||
|
@ -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;
|
||||
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;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
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);
|
||||
// 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'}`);
|
||||
|
||||
// Restore selection
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
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';
|
||||
|
Reference in New Issue
Block a user