fix(wysiwyg): cursor position

This commit is contained in:
Juergen Kunz
2025-06-24 13:53:47 +00:00
parent 4b2178cedd
commit e0a125c9bd
2 changed files with 165 additions and 62 deletions

View File

@ -332,10 +332,8 @@ export class DeesWysiwygBlock extends DeesElement {
} }
} }
// Update blockElement reference for code blocks // For code blocks, we use the nested editableBlock
if (this.block.type === 'code') { // The blockElement getter will automatically find the right element
this.blockElement = editableBlock;
}
} }
render(): TemplateResult { render(): TemplateResult {
@ -394,33 +392,43 @@ export class DeesWysiwygBlock extends DeesElement {
public focus(): void { public focus(): void {
if (!this.blockElement) return; // 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.blockElement;
if (!editableElement) return;
// Ensure the element is focusable // Ensure the element is focusable
if (!this.blockElement.hasAttribute('contenteditable')) { if (!editableElement.hasAttribute('contenteditable')) {
this.blockElement.setAttribute('contenteditable', 'true'); editableElement.setAttribute('contenteditable', 'true');
} }
this.blockElement.focus(); editableElement.focus();
// If focus failed, try again after a microtask // If focus failed, try again after a microtask
if (document.activeElement !== this.blockElement) { if (document.activeElement !== editableElement && this.shadowRoot?.activeElement !== editableElement) {
Promise.resolve().then(() => { Promise.resolve().then(() => {
this.blockElement.focus(); editableElement.focus();
}); });
} }
} }
public focusWithCursor(position: 'start' | 'end' | number = 'end'): void { public focusWithCursor(position: 'start' | 'end' | number = 'end'): void {
if (!this.blockElement) return; // 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.blockElement;
if (!editableElement) return;
// Ensure element is focusable first // Ensure element is focusable first
if (!this.blockElement.hasAttribute('contenteditable')) { if (!editableElement.hasAttribute('contenteditable')) {
this.blockElement.setAttribute('contenteditable', 'true'); editableElement.setAttribute('contenteditable', 'true');
} }
// Focus the element // Focus the element
this.blockElement.focus(); editableElement.focus();
// Set cursor position after focus is established // Set cursor position after focus is established
const setCursor = () => { const setCursor = () => {
@ -430,17 +438,17 @@ export class DeesWysiwygBlock extends DeesElement {
this.setCursorToEnd(); this.setCursorToEnd();
} else if (typeof position === 'number') { } else if (typeof position === 'number') {
// Use the new selection utility to set cursor position // Use the new selection utility to set cursor position
WysiwygSelection.setCursorPosition(this.blockElement, position); WysiwygSelection.setCursorPosition(editableElement, position);
} }
}; };
// Ensure cursor is set after focus // Ensure cursor is set after focus
if (document.activeElement === this.blockElement) { if (document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement) {
setCursor(); setCursor();
} else { } else {
// Wait for focus to be established // Wait for focus to be established
Promise.resolve().then(() => { Promise.resolve().then(() => {
if (document.activeElement === this.blockElement) { if (document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement) {
setCursor(); setCursor();
} }
}); });
@ -461,44 +469,64 @@ export class DeesWysiwygBlock extends DeesElement {
} }
public getContent(): string { public getContent(): string {
if (!this.blockElement) return ''; // 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.blockElement;
if (!editableElement) return '';
if (this.block.type === 'list') { if (this.block.type === 'list') {
const listItems = this.blockElement.querySelectorAll('li'); const listItems = editableElement.querySelectorAll('li');
return Array.from(listItems).map(li => li.innerHTML || '').join('\n'); return Array.from(listItems).map(li => li.innerHTML || '').join('\n');
} else if (this.block.type === 'code') { } else if (this.block.type === 'code') {
return this.blockElement.textContent || ''; return editableElement.textContent || '';
} else { } else {
return this.blockElement.innerHTML || ''; return editableElement.innerHTML || '';
} }
} }
public setContent(content: string): void { public setContent(content: string): void {
if (!this.blockElement) return; // 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.blockElement;
if (!editableElement) return;
// Store if we have focus // Store if we have focus
const hadFocus = document.activeElement === this.blockElement; const hadFocus = document.activeElement === editableElement || this.shadowRoot?.activeElement === editableElement;
if (this.block.type === 'list') { if (this.block.type === 'list') {
this.blockElement.innerHTML = WysiwygBlocks.renderListContent(content, this.block.metadata); editableElement.innerHTML = WysiwygBlocks.renderListContent(content, this.block.metadata);
} else if (this.block.type === 'code') { } else if (this.block.type === 'code') {
this.blockElement.textContent = content; editableElement.textContent = content;
} else { } else {
this.blockElement.innerHTML = content; editableElement.innerHTML = content;
} }
// Restore focus if we had it // Restore focus if we had it
if (hadFocus) { if (hadFocus) {
this.blockElement.focus(); editableElement.focus();
} }
} }
public setCursorToStart(): void { public setCursorToStart(): void {
WysiwygBlocks.setCursorToStart(this.blockElement); const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
if (editableElement) {
WysiwygBlocks.setCursorToStart(editableElement);
}
} }
public setCursorToEnd(): void { public setCursorToEnd(): void {
WysiwygBlocks.setCursorToEnd(this.blockElement); const editableElement = this.block?.type === 'code'
? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
: this.blockElement;
if (editableElement) {
WysiwygBlocks.setCursorToEnd(editableElement);
}
} }
public focusListItem(): void { public focusListItem(): void {
@ -521,50 +549,84 @@ export class DeesWysiwygBlock extends DeesElement {
blockType: this.block.type blockType: this.block.type
}); });
// Get selection info using the new utility that handles Shadow DOM // Direct approach: Get selection from window
const selectionInfo = WysiwygSelection.getSelectionInfo(this.shadowRoot!); const selection = window.getSelection();
if (!selectionInfo) { if (!selection || selection.rangeCount === 0) {
console.log('getSplitContent: No selection, returning all content as before'); console.log('getSplitContent: No selection found');
return { return {
before: fullContent, before: fullContent,
after: '' after: ''
}; };
} }
// Check if selection is within this block const range = selection.getRangeAt(0);
if (!WysiwygSelection.isSelectionInElement(this.blockElement, this.shadowRoot!)) { console.log('getSplitContent: Range info:', {
console.log('getSplitContent: Selection not in this block'); 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; return null;
} }
// Get cursor position as a number // Get the actual editable element (might be nested for code blocks)
const cursorPosition = WysiwygSelection.getCursorPositionInElement(this.blockElement, this.shadowRoot!); const editableElement = this.block.type === 'code'
console.log('getSplitContent: Cursor position:', { ? this.shadowRoot?.querySelector('.block.code') as HTMLDivElement
cursorPosition, : this.blockElement;
contentLength: fullContent.length,
startContainer: selectionInfo.startContainer, if (!editableElement) {
startOffset: selectionInfo.startOffset, console.log('getSplitContent: No editable element found');
collapsed: selectionInfo.collapsed return null;
}); }
// Handle special cases for different block types // Handle special cases for different block types
if (this.block.type === 'code') { if (this.block.type === 'code') {
// For code blocks, split text content // For code blocks, split text content
const fullText = this.blockElement.textContent || ''; const fullText = editableElement.textContent || '';
const textNode = this.getFirstTextNode(this.blockElement); const textNode = this.getFirstTextNode(editableElement);
if (textNode && selectionInfo.startContainer === textNode) { if (textNode && range.startContainer === textNode) {
const before = fullText.substring(0, selectionInfo.startOffset); const before = fullText.substring(0, range.startOffset);
const after = fullText.substring(selectionInfo.startOffset); const after = fullText.substring(range.startOffset);
console.log('getSplitContent: Code block split result:', { console.log('getSplitContent: Code block split result:', {
cursorPosition,
contentLength: fullText.length, contentLength: fullText.length,
beforeContent: before, beforeContent: before,
beforeLength: before.length, beforeLength: before.length,
afterContent: after, afterContent: after,
afterLength: after.length, afterLength: after.length,
startOffset: selectionInfo.startOffset startOffset: range.startOffset
}); });
return { before, after }; return { before, after };
@ -573,15 +635,38 @@ export class DeesWysiwygBlock extends DeesElement {
// For other block types, extract HTML content // For other block types, extract HTML content
try { 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 {
before: beforeText,
after: afterText
};
}
// Create a temporary range to get content before cursor // Create a temporary range to get content before cursor
const beforeRange = document.createRange(); const beforeRange = document.createRange();
beforeRange.selectNodeContents(this.blockElement); beforeRange.selectNodeContents(editableElement);
beforeRange.setEnd(selectionInfo.startContainer, selectionInfo.startOffset); beforeRange.setEnd(range.startContainer, range.startOffset);
// Create a temporary range to get content after cursor // Create a temporary range to get content after cursor
const afterRange = document.createRange(); const afterRange = document.createRange();
afterRange.selectNodeContents(this.blockElement); afterRange.selectNodeContents(editableElement);
afterRange.setStart(selectionInfo.startContainer, selectionInfo.startOffset); afterRange.setStart(range.startContainer, range.startOffset);
// Clone HTML content (not extract, to avoid modifying the DOM) // Clone HTML content (not extract, to avoid modifying the DOM)
const beforeContents = beforeRange.cloneContents(); const beforeContents = beforeRange.cloneContents();
@ -602,7 +687,6 @@ export class DeesWysiwygBlock extends DeesElement {
}; };
console.log('getSplitContent: Split result:', { console.log('getSplitContent: Split result:', {
cursorPosition,
contentLength: fullContent.length, contentLength: fullContent.length,
beforeContent: result.before, beforeContent: result.before,
beforeLength: result.before.length, beforeLength: result.before.length,

View File

@ -100,9 +100,9 @@ export class WysiwygSelection {
/** /**
* Gets cursor position relative to a specific element * Gets cursor position relative to a specific element
*/ */
static getCursorPositionInElement(element: Element, shadowRoot?: ShadowRoot): number | null { static getCursorPositionInElement(element: Element, ...shadowRoots: ShadowRoot[]): number | null {
const selectionInfo = shadowRoot const selectionInfo = shadowRoots.length > 0
? this.getSelectionInfo(shadowRoot) ? this.getSelectionInfo(...shadowRoots)
: this.getSelectionInfo(); : this.getSelectionInfo();
if (!selectionInfo || !selectionInfo.collapsed) return null; if (!selectionInfo || !selectionInfo.collapsed) return null;
@ -111,9 +111,28 @@ export class WysiwygSelection {
try { try {
const range = document.createRange(); const range = document.createRange();
range.selectNodeContents(element); range.selectNodeContents(element);
range.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
// Handle case where selection is in a text node that's a child of the element
if (element.contains(selectionInfo.startContainer)) {
range.setEnd(selectionInfo.startContainer, selectionInfo.startOffset);
return range.toString().length; return range.toString().length;
} 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) { } catch (error) {
console.warn('Failed to get cursor position:', error); console.warn('Failed to get cursor position:', error);
return null; return null;