2025-06-23 21:15:04 +00:00
|
|
|
import { html, type TemplateResult } from '@design.estate/dees-element';
|
2025-06-24 13:41:12 +00:00
|
|
|
import { WysiwygSelection } from './wysiwyg.selection.js';
|
2025-06-23 21:15:04 +00:00
|
|
|
|
|
|
|
export interface IFormatButton {
|
|
|
|
command: string;
|
|
|
|
icon: string;
|
|
|
|
label: string;
|
|
|
|
shortcut?: string;
|
|
|
|
action?: () => void;
|
|
|
|
}
|
|
|
|
|
2025-06-24 17:09:19 +00:00
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
*/
|
2025-06-23 21:15:04 +00:00
|
|
|
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`
|
|
|
|
<div
|
|
|
|
class="formatting-menu"
|
|
|
|
style="top: ${position.y}px; left: ${position.x}px;"
|
|
|
|
@mousedown="${(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); }}"
|
|
|
|
@click="${(e: MouseEvent) => e.stopPropagation()}"
|
|
|
|
>
|
|
|
|
${this.formatButtons.map(button => html`
|
|
|
|
<button
|
|
|
|
class="format-button ${button.command}"
|
|
|
|
@click="${() => onFormat(button.command)}"
|
|
|
|
title="${button.label}${button.shortcut ? ` (${button.shortcut})` : ''}"
|
|
|
|
>
|
|
|
|
<span class="${button.command === 'code' ? 'code-icon' : ''}">${button.icon}</span>
|
|
|
|
</button>
|
|
|
|
`)}
|
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
|
2025-06-24 17:09:19 +00:00
|
|
|
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);
|
|
|
|
}
|
2025-06-23 21:15:04 +00:00
|
|
|
|
|
|
|
// Apply format based on command
|
|
|
|
switch (command) {
|
|
|
|
case 'bold':
|
2025-06-24 17:09:19 +00:00
|
|
|
this.wrapSelection(workingRange, 'strong');
|
2025-06-24 10:45:06 +00:00
|
|
|
break;
|
|
|
|
|
2025-06-23 21:15:04 +00:00
|
|
|
case 'italic':
|
2025-06-24 17:09:19 +00:00
|
|
|
this.wrapSelection(workingRange, 'em');
|
2025-06-24 10:45:06 +00:00
|
|
|
break;
|
|
|
|
|
2025-06-23 21:15:04 +00:00
|
|
|
case 'underline':
|
2025-06-24 17:09:19 +00:00
|
|
|
this.wrapSelection(workingRange, 'u');
|
2025-06-24 10:45:06 +00:00
|
|
|
break;
|
|
|
|
|
2025-06-23 21:15:04 +00:00
|
|
|
case 'strikeThrough':
|
2025-06-24 17:09:19 +00:00
|
|
|
this.wrapSelection(workingRange, 's');
|
2025-06-23 21:15:04 +00:00
|
|
|
break;
|
|
|
|
|
|
|
|
case 'code':
|
2025-06-24 17:09:19 +00:00
|
|
|
this.wrapSelection(workingRange, 'code');
|
2025-06-23 21:15:04 +00:00
|
|
|
break;
|
|
|
|
|
|
|
|
case 'link':
|
2025-06-24 10:45:06 +00:00
|
|
|
// Don't use prompt - return false to indicate we need async input
|
|
|
|
if (!value) {
|
|
|
|
return false;
|
2025-06-23 21:15:04 +00:00
|
|
|
}
|
2025-06-24 17:09:19 +00:00
|
|
|
this.wrapSelectionWithLink(workingRange, value);
|
2025-06-23 21:15:04 +00:00
|
|
|
break;
|
|
|
|
}
|
2025-06-24 10:45:06 +00:00
|
|
|
|
2025-06-24 17:09:19 +00:00
|
|
|
// 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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
private static wrapSelection(range: Range, tagName: string): void {
|
|
|
|
const selection = window.getSelection();
|
|
|
|
if (!selection) return;
|
|
|
|
|
2025-06-24 17:09:19 +00:00
|
|
|
// 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);
|
2025-06-24 10:45:06 +00:00
|
|
|
} else {
|
2025-06-24 17:09:19 +00:00
|
|
|
// Wrap selection with the tag
|
2025-06-24 10:45:06 +00:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-06-24 17:09:19 +00:00
|
|
|
/**
|
|
|
|
* 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;
|
|
|
|
}
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
private static wrapSelectionWithLink(range: Range, url: string): void {
|
|
|
|
const selection = window.getSelection();
|
|
|
|
if (!selection) return;
|
|
|
|
|
2025-06-24 17:09:19 +00:00
|
|
|
// 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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-06-24 10:45:06 +00:00
|
|
|
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);
|
|
|
|
}
|
2025-06-23 21:15:04 +00:00
|
|
|
}
|
|
|
|
|
2025-06-24 16:17:00 +00:00
|
|
|
static getSelectionCoordinates(...shadowRoots: ShadowRoot[]): { x: number, y: number } | null {
|
2025-06-24 13:41:12 +00:00
|
|
|
// Get selection info using the new utility that handles Shadow DOM
|
2025-06-24 16:17:00 +00:00
|
|
|
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
|
2025-06-23 21:15:04 +00:00
|
|
|
|
2025-06-24 13:41:12 +00:00
|
|
|
console.log('getSelectionCoordinates - selectionInfo:', selectionInfo);
|
2025-06-23 21:15:04 +00:00
|
|
|
|
2025-06-24 13:41:12 +00:00
|
|
|
if (!selectionInfo) {
|
|
|
|
console.log('No selection info available');
|
2025-06-23 21:15:04 +00:00
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2025-06-24 13:41:12 +00:00
|
|
|
// Create a range from the selection info to get bounding rect
|
|
|
|
const range = WysiwygSelection.createRangeFromInfo(selectionInfo);
|
2025-06-23 21:15:04 +00:00
|
|
|
const rect = range.getBoundingClientRect();
|
|
|
|
|
|
|
|
console.log('Range rect:', rect);
|
|
|
|
|
2025-06-24 16:17:00 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2025-06-23 21:15:04 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|