Files
dees-catalog/ts_web/elements/wysiwyg/wysiwyg.selection.ts

296 lines
9.6 KiB
TypeScript
Raw Normal View History

2025-06-24 13:41:12 +00:00
/**
* Utilities for handling selection across Shadow DOM boundaries
*/
export interface SelectionInfo {
startContainer: Node;
startOffset: number;
endContainer: Node;
endOffset: number;
collapsed: boolean;
}
2025-06-24 15:17:37 +00:00
// Type for the extended caretPositionFromPoint with Shadow DOM support
type CaretPositionFromPointExtended = (x: number, y: number, ...shadowRoots: ShadowRoot[]) => CaretPosition | null;
2025-06-24 13:41:12 +00:00
export class WysiwygSelection {
/**
* Gets selection info that works across Shadow DOM boundaries
* @param shadowRoots - Shadow roots to include in the selection search
*/
static getSelectionInfo(...shadowRoots: ShadowRoot[]): SelectionInfo | null {
const selection = window.getSelection();
console.log('WysiwygSelection.getSelectionInfo - selection:', selection, 'rangeCount:', selection?.rangeCount);
2025-06-24 13:41:12 +00:00
if (!selection) return null;
// Try using getComposedRanges if available (better Shadow DOM support)
if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function') {
console.log('Using getComposedRanges with', shadowRoots.length, 'shadow roots');
2025-06-24 13:41:12 +00:00
try {
2025-06-24 15:17:37 +00:00
// Pass shadow roots in the correct format as per MDN
const ranges = selection.getComposedRanges({ shadowRoots });
console.log('getComposedRanges returned', ranges.length, 'ranges');
2025-06-24 13:41:12 +00:00
if (ranges.length > 0) {
const range = ranges[0];
return {
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset,
collapsed: range.collapsed
};
}
} catch (error) {
console.warn('getComposedRanges failed, falling back to getRangeAt:', error);
}
} else {
console.log('getComposedRanges not available, using fallback');
2025-06-24 13:41:12 +00:00
}
// Fallback to traditional selection API
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
return {
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset,
collapsed: range.collapsed
};
}
return null;
}
/**
* Checks if a selection is within a specific element (considering Shadow DOM)
*/
static isSelectionInElement(element: Element, shadowRoot?: ShadowRoot): boolean {
const selectionInfo = shadowRoot
? this.getSelectionInfo(shadowRoot)
: this.getSelectionInfo();
if (!selectionInfo) return false;
// Check if the selection's common ancestor is within the element
return element.contains(selectionInfo.startContainer) ||
element.contains(selectionInfo.endContainer);
}
/**
* Gets the selected text across Shadow DOM boundaries
*/
static getSelectedText(): string {
const selection = window.getSelection();
return selection ? selection.toString() : '';
}
/**
* Creates a range from selection info
*/
static createRangeFromInfo(info: SelectionInfo): Range {
const range = document.createRange();
range.setStart(info.startContainer, info.startOffset);
range.setEnd(info.endContainer, info.endOffset);
return range;
}
/**
* Sets selection from a range (works with Shadow DOM)
*/
static setSelectionFromRange(range: Range): void {
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
}
/**
* Gets cursor position relative to a specific element
*/
2025-06-24 13:53:47 +00:00
static getCursorPositionInElement(element: Element, ...shadowRoots: ShadowRoot[]): number | null {
const selectionInfo = shadowRoots.length > 0
? this.getSelectionInfo(...shadowRoots)
2025-06-24 13:41:12 +00:00
: this.getSelectionInfo();
if (!selectionInfo || !selectionInfo.collapsed) return null;
// Create a range from start of element to cursor position
try {
const range = document.createRange();
range.selectNodeContents(element);
2025-06-24 13:53:47 +00:00
// Handle case where selection is in a text node that's a child of the element
2025-06-24 23:46:52 +00:00
// Use our Shadow DOM-aware contains method
const isContained = this.containsAcrossShadowDOM(element, selectionInfo.startContainer);
console.log('getCursorPositionInElement debug:', {
element: element.tagName,
elementText: element.textContent,
selectionContainer: selectionInfo.startContainer,
selectionOffset: selectionInfo.startOffset,
isContained,
elementShadowRoot: element.getRootNode(),
selectionShadowRoot: selectionInfo.startContainer.getRootNode()
});
if (isContained) {
2025-06-24 13:53:47 +00:00
range.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
2025-06-24 23:46:52 +00:00
const position = range.toString().length;
console.log('Cursor position calculated:', position);
return position;
2025-06-24 13:53:47 +00:00
} else {
// Selection might be in shadow DOM or different context
// Try to find the equivalent position in the element
const text = element.textContent || '';
const selectionText = selectionInfo.startContainer.textContent || '';
// If the selection is at the beginning or end, handle those cases
if (selectionInfo.startOffset === 0) {
2025-06-24 23:46:52 +00:00
console.log('Fallback: returning 0 (beginning)');
2025-06-24 13:53:47 +00:00
return 0;
} else if (selectionInfo.startOffset === selectionText.length) {
2025-06-24 23:46:52 +00:00
console.log('Fallback: returning text length:', text.length);
2025-06-24 13:53:47 +00:00
return text.length;
}
// For other cases, try to match based on text content
console.warn('Selection container not within element, using text matching fallback');
return selectionInfo.startOffset;
}
2025-06-24 13:41:12 +00:00
} catch (error) {
console.warn('Failed to get cursor position:', error);
return null;
}
}
2025-06-24 15:17:37 +00:00
/**
* Gets cursor position from mouse coordinates with Shadow DOM support
*/
static getCursorPositionFromPoint(x: number, y: number, container: HTMLElement, ...shadowRoots: ShadowRoot[]): number | null {
// Try modern API with shadow root support
if ('caretPositionFromPoint' in document && document.caretPositionFromPoint) {
let caretPos: CaretPosition | null = null;
// Try with shadow roots first (newer API)
try {
caretPos = (document.caretPositionFromPoint as any)(x, y, ...shadowRoots);
} catch (e) {
// Fallback to standard API without shadow roots
caretPos = document.caretPositionFromPoint(x, y);
}
if (caretPos && container.contains(caretPos.offsetNode)) {
// Calculate total offset within the container
return this.getOffsetInElement(caretPos.offsetNode, caretPos.offset, container);
}
}
// Safari/WebKit fallback
if ('caretRangeFromPoint' in document) {
const range = (document as any).caretRangeFromPoint(x, y);
if (range && container.contains(range.startContainer)) {
return this.getOffsetInElement(range.startContainer, range.startOffset, container);
}
}
return null;
}
/**
* Helper to get the total character offset of a position within an element
*/
private static getOffsetInElement(node: Node, offset: number, container: HTMLElement): number {
let totalOffset = 0;
let found = false;
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
null
);
let textNode: Node | null;
while (textNode = walker.nextNode()) {
if (textNode === node) {
totalOffset += offset;
found = true;
break;
} else {
totalOffset += textNode.textContent?.length || 0;
}
}
return found ? totalOffset : 0;
}
2025-06-24 13:41:12 +00:00
/**
* Sets cursor position in an element
*/
static setCursorPosition(element: Element, position: number): void {
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
null
);
let currentPosition = 0;
let targetNode: Text | null = null;
let targetOffset = 0;
while (walker.nextNode()) {
const node = walker.currentNode as Text;
const nodeLength = node.textContent?.length || 0;
if (currentPosition + nodeLength >= position) {
targetNode = node;
targetOffset = position - currentPosition;
break;
}
currentPosition += nodeLength;
}
if (targetNode) {
const range = document.createRange();
range.setStart(targetNode, targetOffset);
range.collapse(true);
this.setSelectionFromRange(range);
}
}
2025-06-24 22:45:50 +00:00
/**
* Check if a node is contained within an element across Shadow DOM boundaries
* This is needed because element.contains() doesn't work across Shadow DOM
*/
static containsAcrossShadowDOM(container: Node, node: Node): boolean {
if (!container || !node) return false;
// Start with the node and traverse up
let current: Node | null = node;
while (current) {
// Direct match
if (current === container) {
return true;
}
// If we're at a shadow root, check its host
if (current.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (current as any).host) {
const shadowRoot = current as ShadowRoot;
// Check if the container is within this shadow root
if (shadowRoot.contains(container)) {
return false; // Container is in a child shadow DOM
}
// Move to the host element
current = shadowRoot.host;
} else {
// Regular DOM traversal
current = current.parentNode;
}
}
return false;
}
2025-06-24 13:41:12 +00:00
}