This commit is contained in:
Juergen Kunz
2025-06-24 15:17:37 +00:00
parent 75637c7793
commit 83f153f654
3 changed files with 323 additions and 306 deletions

View File

@ -6,7 +6,6 @@ import {
type TemplateResult, type TemplateResult,
cssManager, cssManager,
css, css,
query,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import { type IBlock } from './wysiwyg.types.js'; import { type IBlock } from './wysiwyg.types.js';
@ -38,12 +37,15 @@ export class DeesWysiwygBlock extends DeesElement {
onMouseUp?: (e: MouseEvent) => void; onMouseUp?: (e: MouseEvent) => void;
}; };
@query('.block') // Reference to the editable block element
private blockElement: HTMLDivElement; private blockElement: HTMLDivElement | null = null;
// Track if we've initialized the content // Track if we've initialized the content
private contentInitialized: boolean = false; private contentInitialized: boolean = false;
// Track cursor position
private lastKnownCursorPosition: number = 0;
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
css` css`
@ -270,10 +272,16 @@ export class DeesWysiwygBlock extends DeesElement {
// Mark that content has been initialized // Mark that content has been initialized
this.contentInitialized = true; this.contentInitialized = true;
// For code blocks, the actual contenteditable block is nested // First, populate the container with the rendered content
const container = this.shadowRoot?.querySelector('.wysiwyg-block-container') as HTMLDivElement;
if (container && this.block) {
container.innerHTML = this.renderBlockContent();
}
// Now find the actual editable block element
const editableBlock = this.block.type === 'code' const editableBlock = this.block.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement; : this.shadowRoot?.querySelector('.block') as HTMLDivElement;
// Ensure the block element maintains its content // Ensure the block element maintains its content
if (editableBlock) { if (editableBlock) {
@ -282,16 +290,31 @@ export class DeesWysiwygBlock extends DeesElement {
// Set up all event handlers manually to avoid Lit re-renders // Set up all event handlers manually to avoid Lit re-renders
editableBlock.addEventListener('input', (e) => { editableBlock.addEventListener('input', (e) => {
this.logCursorPosition('input');
this.handlers?.onInput?.(e as InputEvent); this.handlers?.onInput?.(e as InputEvent);
// Track cursor position after input
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
}); });
editableBlock.addEventListener('keydown', (e) => { editableBlock.addEventListener('keydown', (e) => {
// Track cursor position before keydown
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
this.handlers?.onKeyDown?.(e); this.handlers?.onKeyDown?.(e);
}); });
editableBlock.addEventListener('keyup', (e) => { editableBlock.addEventListener('keyup', (e) => {
this.logCursorPosition('keyup', e); // Track cursor position after key release
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
}
}); });
editableBlock.addEventListener('focus', () => { editableBlock.addEventListener('focus', () => {
@ -311,13 +334,28 @@ export class DeesWysiwygBlock extends DeesElement {
}); });
editableBlock.addEventListener('mouseup', (e) => { editableBlock.addEventListener('mouseup', (e) => {
this.logCursorPosition('mouseup'); // Small delay to let browser set cursor position
setTimeout(() => {
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('Cursor position after mouseup:', pos);
}
}, 0);
this.handleMouseUp(e); this.handleMouseUp(e);
this.handlers?.onMouseUp?.(e); this.handlers?.onMouseUp?.(e);
}); });
editableBlock.addEventListener('click', () => { editableBlock.addEventListener('click', (e: MouseEvent) => {
this.logCursorPosition('click'); // Small delay to let browser set cursor position
setTimeout(() => {
const pos = this.getCursorPosition(editableBlock);
if (pos !== null) {
this.lastKnownCursorPosition = pos;
console.log('Cursor position after click:', pos);
}
}, 0);
}); });
// Set initial content if needed // Set initial content if needed
@ -332,15 +370,23 @@ export class DeesWysiwygBlock extends DeesElement {
} }
} }
// For code blocks, we use the nested editableBlock // Store reference to the block element for quick access
// The blockElement getter will automatically find the right element this.blockElement = editableBlock;
} }
render(): TemplateResult { render(): TemplateResult {
if (!this.block) return html``; if (!this.block) return html``;
// Since we need dynamic content, we'll render an empty container
// and set the innerHTML in firstUpdated
return html`<div class="wysiwyg-block-container"></div>`;
}
private renderBlockContent(): string {
if (!this.block) return '';
if (this.block.type === 'divider') { if (this.block.type === 'divider') {
return html` return `
<div class="block divider" data-block-id="${this.block.id}" data-block-type="${this.block.type}"> <div class="block divider" data-block-id="${this.block.id}" data-block-type="${this.block.type}">
<hr> <hr>
</div> </div>
@ -349,11 +395,12 @@ export class DeesWysiwygBlock extends DeesElement {
if (this.block.type === 'code') { if (this.block.type === 'code') {
const language = this.block.metadata?.language || 'plain text'; const language = this.block.metadata?.language || 'plain text';
return html` const selectedClass = this.isSelected ? ' selected' : '';
return `
<div class="code-block-container"> <div class="code-block-container">
<div class="code-language">${language}</div> <div class="code-language">${language}</div>
<div <div
class="block code ${this.isSelected ? 'selected' : ''}" class="block code${selectedClass}"
contenteditable="true" contenteditable="true"
data-block-type="${this.block.type}" data-block-type="${this.block.type}"
></div> ></div>
@ -362,11 +409,10 @@ export class DeesWysiwygBlock extends DeesElement {
} }
const placeholder = this.getPlaceholder(); const placeholder = this.getPlaceholder();
const selectedClass = this.isSelected ? ' selected' : '';
// Return static HTML without event bindings return `
return html`
<div <div
class="block ${this.block.type} ${this.isSelected ? 'selected' : ''}" class="block ${this.block.type}${selectedClass}"
contenteditable="true" contenteditable="true"
data-placeholder="${placeholder}" data-placeholder="${placeholder}"
></div> ></div>
@ -395,7 +441,7 @@ export class DeesWysiwygBlock extends DeesElement {
// Get the actual editable element (might be nested for code blocks) // Get the actual editable element (might be nested for code blocks)
const editableElement = this.block?.type === 'code' const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement; : this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return; if (!editableElement) return;
@ -418,7 +464,7 @@ export class DeesWysiwygBlock extends DeesElement {
// Get the actual editable element (might be nested for code blocks) // Get the actual editable element (might be nested for code blocks)
const editableElement = this.block?.type === 'code' const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement; : this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return; if (!editableElement) return;
@ -455,24 +501,66 @@ export class DeesWysiwygBlock extends DeesElement {
} }
} }
private getFirstTextNode(node: Node): Text | null {
if (node.nodeType === Node.TEXT_NODE) {
return node as Text;
}
for (let i = 0; i < node.childNodes.length; i++) {
const textNode = this.getFirstTextNode(node.childNodes[i]);
if (textNode) return textNode;
}
/**
* Get cursor position in the editable element
*/
private getCursorPosition(element: HTMLElement): number | null {
// Get parent wysiwyg component's shadow root
const parentComponent = this.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('getCursorPosition: Selection info from shadow DOMs:', {
selectionInfo,
shadowRootsCount: shadowRoots.length
});
if (!selectionInfo) {
console.log('getCursorPosition: No selection found');
return null; return null;
} }
console.log('getCursorPosition: Range info:', {
startContainer: selectionInfo.startContainer,
startOffset: selectionInfo.startOffset,
collapsed: selectionInfo.collapsed,
startContainerText: selectionInfo.startContainer.textContent
});
if (!element.contains(selectionInfo.startContainer)) {
console.log('getCursorPosition: Range not in element');
return null;
}
// Create a range from start of element to cursor position
const preCaretRange = document.createRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Get the text content length up to cursor
const position = preCaretRange.toString().length;
console.log('getCursorPosition: Calculated position:', {
position,
preCaretText: preCaretRange.toString(),
elementText: element.textContent,
elementTextLength: element.textContent?.length
});
return position;
}
public getContent(): string { public getContent(): string {
// Get the actual editable element (might be nested for code blocks) // Get the actual editable element (might be nested for code blocks)
const editableElement = this.block?.type === 'code' const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement; : this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return ''; if (!editableElement) return '';
@ -490,7 +578,7 @@ export class DeesWysiwygBlock extends DeesElement {
// Get the actual editable element (might be nested for code blocks) // Get the actual editable element (might be nested for code blocks)
const editableElement = this.block?.type === 'code' const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement; : this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) return; if (!editableElement) return;
@ -531,7 +619,10 @@ export class DeesWysiwygBlock extends DeesElement {
public focusListItem(): void { public focusListItem(): void {
if (this.block.type === 'list') { if (this.block.type === 'list') {
WysiwygBlocks.focusListItem(this.blockElement); const editableElement = this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (editableElement) {
WysiwygBlocks.focusListItem(editableElement);
}
} }
} }
@ -539,179 +630,149 @@ export class DeesWysiwygBlock extends DeesElement {
* Gets content split at cursor position * Gets content split at cursor position
*/ */
public getSplitContent(): { before: string; after: string } | null { public getSplitContent(): { before: string; after: string } | null {
if (!this.blockElement) return null; console.log('getSplitContent: Starting...');
// Get the full content first // Get the actual editable element first
const fullContent = this.getContent(); const editableElement = this.block?.type === 'code'
console.log('getSplitContent: Full content:', {
content: fullContent,
length: fullContent.length,
blockType: this.block.type
});
// Direct approach: Get selection from window
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
console.log('getSplitContent: No selection found');
return {
before: fullContent,
after: ''
};
}
const range = selection.getRangeAt(0);
console.log('getSplitContent: Range info:', {
startContainer: range.startContainer,
startOffset: range.startOffset,
collapsed: range.collapsed,
startContainerType: range.startContainer.nodeType,
startContainerText: range.startContainer.textContent?.substring(0, 50)
});
// Check if this block element has focus or contains the selection
const activeElement = this.shadowRoot?.activeElement || document.activeElement;
const hasFocus = this.blockElement === activeElement || this.blockElement?.contains(activeElement as Node);
// For contenteditable, check if selection is in our shadow DOM
let selectionInThisBlock = false;
try {
// Walk up from the selection to see if we reach our block element
let node: Node | null = range.startContainer;
while (node) {
if (node === this.blockElement || node === this.shadowRoot) {
selectionInThisBlock = true;
break;
}
node = node.parentNode || (node as any).host; // Check shadow host too
}
} catch (e) {
console.log('Error checking selection ancestry:', e);
}
console.log('getSplitContent: Focus check:', {
hasFocus,
selectionInThisBlock,
activeElement,
blockElement: this.blockElement
});
if (!hasFocus && !selectionInThisBlock) {
console.log('getSplitContent: Block does not have focus/selection');
return null;
}
// Get the actual editable element (might be nested for code blocks)
const editableElement = this.block.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement; : this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (!editableElement) { if (!editableElement) {
console.log('getSplitContent: No editable element found'); console.log('getSplitContent: No editable element found');
return null; return null;
} }
// Handle special cases for different block types console.log('getSplitContent: Element info:', {
if (this.block.type === 'code') { blockType: this.block.type,
// For code blocks, split text content innerHTML: editableElement.innerHTML,
textContent: editableElement.textContent,
textLength: editableElement.textContent?.length
});
// Get parent wysiwyg component's shadow root
const parentComponent = this.closest('dees-input-wysiwyg');
const parentShadowRoot = parentComponent?.shadowRoot;
// Get selection info with both shadow roots for proper traversal
const shadowRoots: ShadowRoot[] = [];
if (parentShadowRoot) shadowRoots.push(parentShadowRoot);
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
console.log('getSplitContent: Selection info from shadow DOMs:', {
selectionInfo,
shadowRootsCount: shadowRoots.length
});
if (!selectionInfo) {
console.log('getSplitContent: No selection, using last known position:', this.lastKnownCursorPosition);
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = editableElement.textContent || ''; const fullText = editableElement.textContent || '';
const textNode = this.getFirstTextNode(editableElement); const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
console.log('getSplitContent: Splitting with last known position:', {
if (textNode && range.startContainer === textNode) { pos,
const before = fullText.substring(0, range.startOffset); fullTextLength: fullText.length,
const after = fullText.substring(range.startOffset); before: fullText.substring(0, pos),
after: fullText.substring(pos)
console.log('getSplitContent: Code block split result:', {
contentLength: fullText.length,
beforeContent: before,
beforeLength: before.length,
afterContent: after,
afterLength: after.length,
startOffset: range.startOffset
}); });
return { before, after };
}
}
// For other block types, extract HTML content
try {
// If selection is not directly in our element, try to find cursor position by text
if (!editableElement.contains(range.startContainer)) {
// Simple approach: split at cursor position in text
const textContent = editableElement.textContent || '';
const cursorPos = range.startOffset; // Simplified cursor position
const beforeText = textContent.substring(0, cursorPos);
const afterText = textContent.substring(cursorPos);
console.log('Splitting by text position (fallback):', {
cursorPos,
beforeText,
afterText,
totalLength: textContent.length
});
// For now, return text-based split
return { return {
before: beforeText, before: fullText.substring(0, pos),
after: afterText after: fullText.substring(pos)
};
}
return null;
}
console.log('getSplitContent: Selection range:', {
startContainer: selectionInfo.startContainer,
startOffset: selectionInfo.startOffset,
startContainerInElement: editableElement.contains(selectionInfo.startContainer)
});
// Make sure the selection is within this block
if (!editableElement.contains(selectionInfo.startContainer)) {
console.log('getSplitContent: Selection not in this block, using last known position:', this.lastKnownCursorPosition);
// Try using last known cursor position
if (this.lastKnownCursorPosition !== null) {
const fullText = editableElement.textContent || '';
const pos = Math.min(this.lastKnownCursorPosition, fullText.length);
return {
before: fullText.substring(0, pos),
after: fullText.substring(pos)
};
}
return null;
}
// For code blocks, use simple text splitting
if (this.block.type === 'code') {
const cursorPos = this.getCursorPosition(editableElement) || 0;
const fullText = editableElement.textContent || '';
console.log('getSplitContent: Code block split:', {
cursorPos,
fullTextLength: fullText.length,
before: fullText.substring(0, cursorPos),
after: fullText.substring(cursorPos)
});
return {
before: fullText.substring(0, cursorPos),
after: fullText.substring(cursorPos)
}; };
} }
// Create a temporary range to get content before cursor // For HTML content, get cursor position first
const cursorPos = this.getCursorPosition(editableElement);
console.log('getSplitContent: Cursor position for HTML split:', cursorPos);
if (cursorPos === null || cursorPos === 0) {
// If cursor is at start or can't determine position, move all content
console.log('getSplitContent: Cursor at start or null, moving all content');
return {
before: '',
after: editableElement.innerHTML
};
}
// For HTML content, split using ranges to preserve formatting
const beforeRange = document.createRange(); const beforeRange = document.createRange();
beforeRange.selectNodeContents(editableElement);
beforeRange.setEnd(range.startContainer, range.startOffset);
// Create a temporary range to get content after cursor
const afterRange = document.createRange(); const afterRange = document.createRange();
afterRange.selectNodeContents(editableElement);
afterRange.setStart(range.startContainer, range.startOffset);
// Clone HTML content (not extract, to avoid modifying the DOM) // Before range: from start of element to cursor
const beforeContents = beforeRange.cloneContents(); beforeRange.setStart(editableElement, 0);
const afterContents = afterRange.cloneContents(); beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// After range: from cursor to end of element
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset);
afterRange.setEnd(editableElement, editableElement.childNodes.length);
// Extract HTML content
const beforeFragment = beforeRange.cloneContents();
const afterFragment = afterRange.cloneContents();
// Convert to HTML strings // Convert to HTML strings
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.appendChild(beforeContents); tempDiv.appendChild(beforeFragment);
const beforeHtml = tempDiv.innerHTML; const beforeHtml = tempDiv.innerHTML;
tempDiv.innerHTML = ''; tempDiv.innerHTML = '';
tempDiv.appendChild(afterContents); tempDiv.appendChild(afterFragment);
const afterHtml = tempDiv.innerHTML; const afterHtml = tempDiv.innerHTML;
const result = { console.log('getSplitContent: Final split result:', {
cursorPos,
beforeHtml,
beforeLength: beforeHtml.length,
afterHtml,
afterLength: afterHtml.length
});
return {
before: beforeHtml, before: beforeHtml,
after: afterHtml after: afterHtml
}; };
console.log('getSplitContent: Split result:', {
contentLength: fullContent.length,
beforeContent: result.before,
beforeLength: result.before.length,
afterContent: result.after,
afterLength: result.after.length
});
return result;
} catch (error) {
console.error('Error splitting content:', error);
// Fallback: return all content as "before"
const fallbackResult = {
before: this.getContent(),
after: ''
};
console.log('getSplitContent: Fallback result:', {
beforeContent: fallbackResult.before,
beforeLength: fallbackResult.before.length,
afterContent: fallbackResult.after,
afterLength: fallbackResult.after.length
});
return fallbackResult;
}
} }
private handleMouseUp(_e: MouseEvent): void { private handleMouseUp(_e: MouseEvent): void {
@ -722,7 +783,10 @@ export class DeesWysiwygBlock extends DeesElement {
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
// Check if selection is within this block // Check if selection is within this block
if (this.blockElement && this.blockElement.contains(range.commonAncestorContainer)) { const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (editableElement && editableElement.contains(range.commonAncestorContainer)) {
const selectedText = selection.toString(); const selectedText = selection.toString();
if (selectedText.length > 0) { if (selectedText.length > 0) {
// Dispatch a custom event that can cross shadow DOM boundaries // Dispatch a custom event that can cross shadow DOM boundaries
@ -740,117 +804,4 @@ export class DeesWysiwygBlock extends DeesElement {
} }
}, 10); }, 10);
} }
/**
* Logs cursor position for debugging
*/
private logCursorPosition(eventType: string, event?: KeyboardEvent): void {
console.log(`[CursorLog] Event triggered: ${eventType} in block ${this.block.id}`);
// Get the actual active element considering shadow DOM
const activeElement = this.shadowRoot?.activeElement;
console.log(`[CursorLog] Active element:`, activeElement, 'Block element:', this.blockElement);
// Only log if this block is focused
if (activeElement !== this.blockElement) {
console.log(`[CursorLog] Block not focused, skipping detailed logging`);
return;
}
// Get selection info using the new utility that handles Shadow DOM
const selectionInfo = WysiwygSelection.getSelectionInfo(this.shadowRoot!);
if (!selectionInfo) {
console.log(`[${eventType}] No selection available`);
return;
}
const isInThisBlock = WysiwygSelection.isSelectionInElement(this.blockElement, this.shadowRoot!);
if (!isInThisBlock) {
return;
}
// Get cursor position details
const details: any = {
event: eventType,
blockId: this.block.id,
blockType: this.block.type,
collapsed: selectionInfo.collapsed,
startContainer: {
nodeType: selectionInfo.startContainer.nodeType,
nodeName: selectionInfo.startContainer.nodeName,
textContent: selectionInfo.startContainer.textContent?.substring(0, 50) + '...',
},
startOffset: selectionInfo.startOffset,
};
// Add key info if it's a keyboard event
if (event) {
details.key = event.key;
details.shiftKey = event.shiftKey;
details.ctrlKey = event.ctrlKey;
details.metaKey = event.metaKey;
}
// Try to get the actual cursor position in the text
if (selectionInfo.startContainer.nodeType === Node.TEXT_NODE) {
const textNode = selectionInfo.startContainer as Text;
const textBefore = textNode.textContent?.substring(0, selectionInfo.startOffset) || '';
const textAfter = textNode.textContent?.substring(selectionInfo.startOffset) || '';
details.cursorPosition = {
textBefore: textBefore.slice(-20), // Last 20 chars before cursor
textAfter: textAfter.slice(0, 20), // First 20 chars after cursor
totalLength: textNode.textContent?.length || 0,
offset: selectionInfo.startOffset
};
}
// Check if we're at boundaries
details.boundaries = {
atStart: this.isCursorAtStart(selectionInfo),
atEnd: this.isCursorAtEnd(selectionInfo)
};
console.log('Cursor Position:', details);
}
/**
* Check if cursor is at the start of the block
*/
private isCursorAtStart(selectionInfo: { startContainer: Node; startOffset: number; collapsed: boolean }): boolean {
if (!selectionInfo.collapsed || selectionInfo.startOffset !== 0) return false;
const firstNode = this.getFirstTextNode(this.blockElement);
return !firstNode || selectionInfo.startContainer === firstNode || selectionInfo.startContainer === this.blockElement;
}
/**
* Check if cursor is at the end of the block
*/
private isCursorAtEnd(selectionInfo: { endContainer: Node; endOffset: number; collapsed: boolean }): boolean {
if (!selectionInfo.collapsed) return false;
const lastNode = this.getLastTextNode(this.blockElement);
if (!lastNode) return true;
return selectionInfo.endContainer === lastNode &&
selectionInfo.endOffset === (lastNode.textContent?.length || 0);
}
/**
* Get the last text node in the element
*/
private getLastTextNode(node: Node): Text | null {
if (node.nodeType === Node.TEXT_NODE) {
return node as Text;
}
for (let i = node.childNodes.length - 1; i >= 0; i--) {
const lastText = this.getLastTextNode(node.childNodes[i]);
if (lastText) return lastText;
}
return null;
}
} }

View File

@ -3,3 +3,5 @@
* We try to have clean concise and managable code * We try to have clean concise and managable code
* lets log whats happening, so if something goes wrong, we understand whats happening. * lets log whats happening, so if something goes wrong, we understand whats happening.
* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges * Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges
* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges
* Make sure to hand over correct shodowroots.

View File

@ -10,6 +10,9 @@ export interface SelectionInfo {
collapsed: boolean; collapsed: boolean;
} }
// Type for the extended caretPositionFromPoint with Shadow DOM support
type CaretPositionFromPointExtended = (x: number, y: number, ...shadowRoots: ShadowRoot[]) => CaretPosition | null;
export class WysiwygSelection { export class WysiwygSelection {
/** /**
* Gets selection info that works across Shadow DOM boundaries * Gets selection info that works across Shadow DOM boundaries
@ -22,7 +25,8 @@ export class WysiwygSelection {
// Try using getComposedRanges if available (better Shadow DOM support) // Try using getComposedRanges if available (better Shadow DOM support)
if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function') { if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function') {
try { try {
const ranges = selection.getComposedRanges(...shadowRoots); // Pass shadow roots in the correct format as per MDN
const ranges = selection.getComposedRanges({ shadowRoots });
if (ranges.length > 0) { if (ranges.length > 0) {
const range = ranges[0]; const range = ranges[0];
return { return {
@ -139,6 +143,66 @@ export class WysiwygSelection {
} }
} }
/**
* 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 * Sets cursor position in an element
*/ */