fix(wysiwyg): Fix text selection detection for formatting menu in Shadow DOM

- Update selection detection to properly handle Shadow DOM boundaries
- Use getComposedRanges API correctly according to MDN documentation
- Add direct selection detection within block components
- Dispatch custom events from blocks when text is selected
- Fix formatting menu positioning using selection rect from events
This commit is contained in:
Juergen Kunz
2025-06-24 16:17:00 +00:00
parent ca525ce7e3
commit 3b93bd63a7
5 changed files with 320 additions and 110 deletions

View File

@ -162,9 +162,11 @@ export class DeesFormattingMenu extends DeesElement {
} }
public show(position: { x: number; y: number }, callback: (command: string) => void | Promise<void>): void { public show(position: { x: number; y: number }, callback: (command: string) => void | Promise<void>): void {
console.log('FormattingMenu.show called:', { position, visible: this.visible });
this.position = position; this.position = position;
this.callback = callback; this.callback = callback;
this.visible = true; this.visible = true;
console.log('FormattingMenu.show - visible set to:', this.visible);
} }
public hide(): void { public hide(): void {

View File

@ -24,6 +24,7 @@ import {
WysiwygDragDropHandler, WysiwygDragDropHandler,
WysiwygModalManager, WysiwygModalManager,
WysiwygHistory, WysiwygHistory,
WysiwygSelection,
DeesSlashMenu, DeesSlashMenu,
DeesFormattingMenu DeesFormattingMenu
} from './index.js'; } from './index.js';
@ -138,12 +139,39 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
console.log('Adding selectionchange listener'); console.log('Adding selectionchange listener');
document.addEventListener('selectionchange', this.selectionChangeHandler); document.addEventListener('selectionchange', this.selectionChangeHandler);
// Also add listener to our shadow root if supported
if (this.shadowRoot && 'addEventListener' in this.shadowRoot) {
this.shadowRoot.addEventListener('selectionchange', this.selectionChangeHandler);
}
// Listen for custom selection events from blocks // Listen for custom selection events from blocks
this.addEventListener('block-text-selected', (e: CustomEvent) => { this.addEventListener('block-text-selected', (e: CustomEvent) => {
console.log('Received block-text-selected event:', e.detail);
if (!this.slashMenu.visible) { if (!this.slashMenu.visible) {
if (e.detail.hasSelection && e.detail.text.length > 0) {
this.selectedText = e.detail.text; this.selectedText = e.detail.text;
// Use the rect from the event if available
if (e.detail.rect) {
const coords = {
x: e.detail.rect.left + (e.detail.rect.width / 2),
y: Math.max(45, e.detail.rect.top - 45)
};
// Show the formatting menu at the calculated position
this.formattingMenu.show(
coords,
async (command: string) => await this.applyFormat(command)
);
} else {
this.updateFormattingMenuPosition(); this.updateFormattingMenuPosition();
} }
} else {
// Clear selection
this.hideFormattingMenu();
}
}
}); });
// Add global keyboard listener for undo/redo // Add global keyboard listener for undo/redo
@ -700,72 +728,157 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
} }
private handleSelectionChange(): void { private handleSelectionChange(): void {
// Try to get selection from shadow root first, then fall back to window console.log('=== handleSelectionChange called ===');
const shadowSelection = (this.shadowRoot as any).getSelection ? (this.shadowRoot as any).getSelection() : null;
const windowSelection = window.getSelection();
const editorContent = this.shadowRoot?.querySelector('.editor-content') as HTMLElement;
// Check both shadow and window selections const selection = window.getSelection();
let selection = shadowSelection; if (!selection || selection.rangeCount === 0) {
let selectedText = shadowSelection?.toString() || ''; if (this.formattingMenu.visible) {
console.log('No selection, hiding menu');
// If no shadow selection, check window selection this.hideFormattingMenu();
if (!selectedText && windowSelection) {
selection = windowSelection;
selectedText = windowSelection.toString() || '';
} }
console.log('Selection change:', {
hasText: selectedText.length > 0,
selectedText: selectedText.substring(0, 50),
shadowSelection: !!shadowSelection,
windowSelection: !!windowSelection,
rangeCount: selection?.rangeCount,
editorContent: !!editorContent
});
if (!selection || selection.rangeCount === 0 || !editorContent) {
console.log('No selection or editor content');
return; return;
} }
// If we have selected text, show the formatting menu const selectedText = selection.toString().trim();
if (selectedText.length > 0) { console.log('Selected text:', selectedText);
console.log('✅ Text selected:', selectedText);
if (selectedText !== this.selectedText) { if (selectedText.length === 0) {
this.selectedText = selectedText; if (this.formattingMenu.visible) {
this.updateFormattingMenuPosition();
}
} else if (this.formattingMenu.visible) {
console.log('No text selected, hiding menu'); console.log('No text selected, hiding menu');
this.hideFormattingMenu(); this.hideFormattingMenu();
} }
return;
}
// Get all shadow roots in our component tree
const shadowRoots: ShadowRoot[] = [];
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
// Find all block shadow roots
const blockWrappers = this.shadowRoot?.querySelectorAll('.block-wrapper');
console.log('Found block wrappers:', blockWrappers?.length || 0);
blockWrappers?.forEach(wrapper => {
const blockComponent = wrapper.querySelector('dees-wysiwyg-block');
if (blockComponent?.shadowRoot) {
shadowRoots.push(blockComponent.shadowRoot);
}
});
console.log('Shadow roots collected:', shadowRoots.length);
// Try using getComposedRanges if available
let ranges: Range[] | StaticRange[] = [];
let selectionInEditor = false;
if ('getComposedRanges' in selection && typeof selection.getComposedRanges === 'function') {
console.log('Using getComposedRanges with shadow roots');
try {
// According to MDN, pass shadow roots in options object
ranges = selection.getComposedRanges({ shadowRoots });
console.log('getComposedRanges returned', ranges.length, 'ranges');
if (ranges.length > 0) {
const range = ranges[0];
// Check if the range is within our editor
selectionInEditor = this.isRangeInEditor(range);
}
} catch (error) {
console.warn('getComposedRanges failed:', error);
}
}
// Fallback to regular selection API
if (ranges.length === 0 && selection.rangeCount > 0) {
console.log('Falling back to getRangeAt');
const range = selection.getRangeAt(0);
ranges = [range];
selectionInEditor = this.isRangeInEditor(range);
}
console.log('Selection in editor:', selectionInEditor);
if (selectionInEditor && selectedText !== this.selectedText) {
console.log('✅ Text selected in editor:', selectedText);
this.selectedText = selectedText;
this.updateFormattingMenuPosition();
} else if (!selectionInEditor && this.formattingMenu.visible) {
console.log('Selection not in editor, hiding menu');
this.hideFormattingMenu();
}
}
private isRangeInEditor(range: Range | StaticRange): boolean {
// Check if the selection is within one of our blocks
const blockWrappers = this.shadowRoot?.querySelectorAll('.block-wrapper');
if (!blockWrappers) return false;
// Check each block
for (let i = 0; i < blockWrappers.length; i++) {
const wrapper = blockWrappers[i];
const blockComponent = wrapper.querySelector('dees-wysiwyg-block');
if (blockComponent?.shadowRoot) {
const editableElements = blockComponent.shadowRoot.querySelectorAll('.block, .block.code');
for (let j = 0; j < editableElements.length; j++) {
const elem = editableElements[j];
if (elem) {
// For StaticRange, we need to check differently
if ('startContainer' in range) {
// Check if the range nodes are within this element
const startInElement = this.isNodeInElement(range.startContainer, elem);
const endInElement = this.isNodeInElement(range.endContainer, elem);
if (startInElement || endInElement) {
console.log('Selection found in block:', wrapper.getAttribute('data-block-id'));
return true;
}
}
}
}
}
}
console.log('Selection not in any block. Range:', range);
return false;
}
/**
* Check if a node is within an element (handles shadow DOM)
*/
private isNodeInElement(node: Node, element: Element): boolean {
let current: Node | null = node;
while (current) {
if (current === element) return true;
// Walk up the tree, including shadow host if in shadow DOM
current = current.parentNode || (current as any).host;
}
return false;
} }
private updateFormattingMenuPosition(): void { private updateFormattingMenuPosition(): void {
console.log('updateFormattingMenuPosition called'); console.log('updateFormattingMenuPosition called');
const coords = WysiwygFormatting.getSelectionCoordinates(this.shadowRoot as ShadowRoot);
// Get all shadow roots
const shadowRoots: ShadowRoot[] = [];
if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
// Find all block shadow roots
const blockWrappers = this.shadowRoot?.querySelectorAll('.block-wrapper');
blockWrappers?.forEach(wrapper => {
const blockComponent = wrapper.querySelector('dees-wysiwyg-block');
if (blockComponent?.shadowRoot) {
shadowRoots.push(blockComponent.shadowRoot);
}
});
const coords = WysiwygFormatting.getSelectionCoordinates(...shadowRoots);
console.log('Selection coordinates:', coords); console.log('Selection coordinates:', coords);
if (coords) { if (coords) {
const container = this.shadowRoot!.querySelector('.wysiwyg-container'); // Show the global formatting menu at absolute coordinates
if (!container) {
console.error('Container not found!');
return;
}
const containerRect = container.getBoundingClientRect();
const formattingMenuPosition = {
x: coords.x - containerRect.left,
y: coords.y - containerRect.top
};
console.log('Setting menu position:', formattingMenuPosition);
// Show the global formatting menu
this.formattingMenu.show( this.formattingMenu.show(
{ x: coords.x, y: coords.y }, // Use absolute coordinates { x: coords.x, y: coords.y },
async (command: string) => await this.applyFormat(command) async (command: string) => await this.applyFormat(command)
); );
} else { } else {
@ -779,33 +892,56 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
} }
public async applyFormat(command: string): Promise<void> { public async applyFormat(command: string): Promise<void> {
// Save current selection before applying format // Get all shadow roots
const selection = window.getSelection(); const shadowRoots: ShadowRoot[] = [];
if (!selection || selection.rangeCount === 0) return; if (this.shadowRoot) shadowRoots.push(this.shadowRoot);
// Get the current block // Find all block shadow roots
const anchorNode = selection.anchorNode; const blockWrappers = this.shadowRoot?.querySelectorAll('.block-wrapper');
const blockElement = anchorNode?.nodeType === Node.TEXT_NODE blockWrappers?.forEach(wrapper => {
? anchorNode.parentElement?.closest('.block') const blockComponent = wrapper.querySelector('dees-wysiwyg-block');
: (anchorNode as Element)?.closest('.block'); if (blockComponent?.shadowRoot) {
shadowRoots.push(blockComponent.shadowRoot);
}
});
if (!blockElement) return; // Get selection info using Shadow DOM-aware utilities
const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
if (!selectionInfo) return;
const blockWrapper = blockElement.closest('.block-wrapper'); // Find which block contains the selection
const blockId = blockWrapper?.getAttribute('data-block-id'); let targetBlock: IBlock | undefined;
const block = this.blocks.find(b => b.id === blockId); let targetBlockComponent: any;
const blockComponent = blockWrapper?.querySelector('dees-wysiwyg-block') as any;
if (!block || !blockComponent) return; const wrappers = this.shadowRoot!.querySelectorAll('.block-wrapper');
for (let i = 0; i < wrappers.length; i++) {
const wrapper = wrappers[i];
const blockComponent = wrapper.querySelector('dees-wysiwyg-block') as any;
if (blockComponent?.shadowRoot) {
const block = blockComponent.shadowRoot.querySelector('.block');
if (block && (
block.contains(selectionInfo.startContainer) ||
block.contains(selectionInfo.endContainer)
)) {
const blockId = wrapper.getAttribute('data-block-id');
targetBlock = this.blocks.find(b => b.id === blockId);
targetBlockComponent = blockComponent;
break;
}
}
}
if (!targetBlock || !targetBlockComponent) return;
// Handle link command specially // Handle link command specially
if (command === 'link') { if (command === 'link') {
const url = await this.showLinkDialog(); const url = await this.showLinkDialog();
if (!url) { if (!url) {
// User cancelled - restore focus to block // User cancelled - restore focus to block
blockComponent.focus(); targetBlockComponent.focus();
return; return;
} }
// Apply link format
WysiwygFormatting.applyFormat(command, url); WysiwygFormatting.applyFormat(command, url);
} else { } else {
// Apply the format // Apply the format
@ -813,10 +949,10 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
} }
// Update content after a microtask to ensure DOM is updated // Update content after a microtask to ensure DOM is updated
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 10));
// Force content update // Force content update
block.content = blockComponent.getContent(); targetBlock.content = targetBlockComponent.getContent();
// Update value to persist changes // Update value to persist changes
this.updateValue(); this.updateValue();
@ -825,14 +961,9 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
if (command === 'link') { if (command === 'link') {
this.hideFormattingMenu(); this.hideFormattingMenu();
} else if (this.formattingMenu.visible) { } else if (this.formattingMenu.visible) {
// Update menu position if still showing // Keep selection and update menu position
this.updateFormattingMenuPosition(); this.updateFormattingMenuPosition();
} }
// Ensure block still has focus
if (document.activeElement !== blockElement) {
blockComponent.focus();
}
} }
private async showLinkDialog(): Promise<string | null> { private async showLinkDialog(): Promise<string | null> {

View File

@ -315,6 +315,13 @@ export class DeesWysiwygBlock extends DeesElement {
if (pos !== null) { if (pos !== null) {
this.lastKnownCursorPosition = pos; this.lastKnownCursorPosition = pos;
} }
// Check for selection after keyboard navigation
if (e.shiftKey || ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
setTimeout(() => {
this.checkForTextSelection();
}, 10);
}
}); });
editableBlock.addEventListener('focus', () => { editableBlock.addEventListener('focus', () => {
@ -341,6 +348,9 @@ export class DeesWysiwygBlock extends DeesElement {
this.lastKnownCursorPosition = pos; this.lastKnownCursorPosition = pos;
console.log('Cursor position after mouseup:', pos); console.log('Cursor position after mouseup:', pos);
} }
// Check for text selection
this.checkForTextSelection();
}, 0); }, 0);
this.handleMouseUp(e); this.handleMouseUp(e);
@ -778,32 +788,75 @@ export class DeesWysiwygBlock extends DeesElement {
} }
private handleMouseUp(_e: MouseEvent): void { private handleMouseUp(_e: MouseEvent): void {
// Check if we have a selection within this block // Selection check is now handled in the mouseup event listener
setTimeout(() => { }
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
// Check if selection is within this block /**
* Check if there's text selected within this block
*/
private checkForTextSelection(): void {
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.shadowRoot?.querySelector('.block') as HTMLDivElement; : this.shadowRoot?.querySelector('.block') as HTMLDivElement;
if (editableElement && editableElement.contains(range.commonAncestorContainer)) { if (!editableElement) return;
const selectedText = selection.toString();
if (selectedText.length > 0) { const selection = window.getSelection();
// Dispatch a custom event that can cross shadow DOM boundaries if (!selection || selection.rangeCount === 0) {
// Dispatch event to clear selection
this.dispatchEvent(new CustomEvent('block-text-selected', {
detail: {
text: '',
blockId: this.block.id,
hasSelection: false
},
bubbles: true,
composed: true
}));
return;
}
const selectedText = selection.toString().trim();
// Only proceed if we have selected text
if (selectedText.length === 0) {
// Dispatch event to clear selection
this.dispatchEvent(new CustomEvent('block-text-selected', {
detail: {
text: '',
blockId: this.block.id,
hasSelection: false
},
bubbles: true,
composed: true
}));
return;
}
// Check if the selection is within this block
const range = selection.getRangeAt(0);
// Check if both start and end are within our editable element
const startInBlock = editableElement.contains(range.startContainer);
const endInBlock = editableElement.contains(range.endContainer);
if (startInBlock && endInBlock) {
console.log('Block detected text selection:', selectedText);
// Get the bounding rect of the selection
const rect = range.getBoundingClientRect();
// Dispatch event to parent with selection details
this.dispatchEvent(new CustomEvent('block-text-selected', { this.dispatchEvent(new CustomEvent('block-text-selected', {
detail: { detail: {
text: selectedText, text: selectedText,
blockId: this.block.id, blockId: this.block.id,
range: range range: range,
rect: rect,
hasSelection: true
}, },
bubbles: true, bubbles: true,
composed: true composed: true
})); }));
} }
} }
}
}, 10);
}
} }

View File

@ -143,11 +143,9 @@ export class WysiwygFormatting {
} }
} }
static getSelectionCoordinates(shadowRoot?: ShadowRoot): { x: number, y: number } | null { static getSelectionCoordinates(...shadowRoots: ShadowRoot[]): { x: number, y: number } | null {
// Get selection info using the new utility that handles Shadow DOM // Get selection info using the new utility that handles Shadow DOM
const selectionInfo = shadowRoot const selectionInfo = WysiwygSelection.getSelectionInfo(...shadowRoots);
? WysiwygSelection.getSelectionInfo(shadowRoot)
: WysiwygSelection.getSelectionInfo();
console.log('getSelectionCoordinates - selectionInfo:', selectionInfo); console.log('getSelectionCoordinates - selectionInfo:', selectionInfo);
@ -162,8 +160,29 @@ export class WysiwygFormatting {
console.log('Range rect:', rect); console.log('Range rect:', rect);
if (rect.width === 0) { if (rect.width === 0 && rect.height === 0) {
console.log('Rect width is 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;
}
}
}
return null; return null;
} }

View File

@ -20,13 +20,16 @@ export class WysiwygSelection {
*/ */
static getSelectionInfo(...shadowRoots: ShadowRoot[]): SelectionInfo | null { static getSelectionInfo(...shadowRoots: ShadowRoot[]): SelectionInfo | null {
const selection = window.getSelection(); const selection = window.getSelection();
console.log('WysiwygSelection.getSelectionInfo - selection:', selection, 'rangeCount:', selection?.rangeCount);
if (!selection) return null; if (!selection) return null;
// 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') {
console.log('Using getComposedRanges with', shadowRoots.length, 'shadow roots');
try { try {
// Pass shadow roots in the correct format as per MDN // Pass shadow roots in the correct format as per MDN
const ranges = selection.getComposedRanges({ shadowRoots }); const ranges = selection.getComposedRanges({ shadowRoots });
console.log('getComposedRanges returned', ranges.length, 'ranges');
if (ranges.length > 0) { if (ranges.length > 0) {
const range = ranges[0]; const range = ranges[0];
return { return {
@ -40,6 +43,8 @@ export class WysiwygSelection {
} catch (error) { } catch (error) {
console.warn('getComposedRanges failed, falling back to getRangeAt:', error); console.warn('getComposedRanges failed, falling back to getRangeAt:', error);
} }
} else {
console.log('getComposedRanges not available, using fallback');
} }
// Fallback to traditional selection API // Fallback to traditional selection API