feat: Add WYSIWYG editor components and utilities
- Implemented WysiwygModalManager for managing modals related to code blocks and block settings. - Created WysiwygSelection for handling text selection across Shadow DOM boundaries. - Introduced WysiwygShortcuts for managing keyboard shortcuts and slash menu items. - Developed wysiwygStyles for consistent styling of the WYSIWYG editor. - Defined types for blocks, slash menu items, and shortcut patterns in wysiwyg.types.ts.
This commit is contained in:
283
ts_web/elements/dees-input-wysiwyg/wysiwyg.selection.ts
Normal file
283
ts_web/elements/dees-input-wysiwyg/wysiwyg.selection.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Utilities for handling selection across Shadow DOM boundaries
|
||||
*/
|
||||
|
||||
export interface SelectionInfo {
|
||||
startContainer: Node;
|
||||
startOffset: number;
|
||||
endContainer: Node;
|
||||
endOffset: number;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
// Type for the extended caretPositionFromPoint with Shadow DOM support
|
||||
type CaretPositionFromPointExtended = (x: number, y: number, ...shadowRoots: ShadowRoot[]) => CaretPosition | null;
|
||||
|
||||
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);
|
||||
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');
|
||||
try {
|
||||
// Pass shadow roots in the correct format as per MDN
|
||||
const ranges = selection.getComposedRanges({ shadowRoots });
|
||||
console.log('getComposedRanges returned', ranges.length, 'ranges');
|
||||
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');
|
||||
}
|
||||
|
||||
// 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
|
||||
*/
|
||||
static getCursorPositionInElement(element: Element, ...shadowRoots: ShadowRoot[]): number | null {
|
||||
const selectionInfo = shadowRoots.length > 0
|
||||
? this.getSelectionInfo(...shadowRoots)
|
||||
: 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);
|
||||
|
||||
// Handle case where selection is in a text node that's a child of the element
|
||||
// Use our Shadow DOM-aware contains method
|
||||
const isContained = this.containsAcrossShadowDOM(element, selectionInfo.startContainer);
|
||||
|
||||
if (isContained) {
|
||||
range.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
|
||||
const position = range.toString().length;
|
||||
return position;
|
||||
} 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) {
|
||||
return 0;
|
||||
} else if (selectionInfo.startOffset === selectionText.length) {
|
||||
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;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to get cursor position:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user