fix(wysiwyg): Improve text selection detection with block-level approach
- Remove global selectionchange listener in favor of block-level detection - Add comprehensive debugging logs to track selection detection - Add multiple event listeners (mouseup, keyup, selectstart) for better coverage - Add debounced selection checking to avoid race conditions - Add click-outside handler to hide formatting menu - Simplify selection detection logic by removing complex shadow DOM traversal
This commit is contained in:
@ -77,14 +77,6 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
|
|
||||||
public editorContentRef: HTMLDivElement;
|
public editorContentRef: HTMLDivElement;
|
||||||
public isComposing: boolean = false;
|
public isComposing: boolean = false;
|
||||||
private selectionChangeTimeout: any;
|
|
||||||
private selectionChangeHandler = () => {
|
|
||||||
// Throttle selection change events
|
|
||||||
if (this.selectionChangeTimeout) {
|
|
||||||
clearTimeout(this.selectionChangeTimeout);
|
|
||||||
}
|
|
||||||
this.selectionChangeTimeout = setTimeout(() => this.handleSelectionChange(), 50);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handler instances
|
// Handler instances
|
||||||
public blockOperations: WysiwygBlockOperations;
|
public blockOperations: WysiwygBlockOperations;
|
||||||
@ -115,8 +107,7 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
|
|
||||||
async disconnectedCallback() {
|
async disconnectedCallback() {
|
||||||
await super.disconnectedCallback();
|
await super.disconnectedCallback();
|
||||||
// Remove selection listener
|
// Selection listeners are now handled at block level
|
||||||
document.removeEventListener('selectionchange', this.selectionChangeHandler);
|
|
||||||
// Clean up handlers
|
// Clean up handlers
|
||||||
this.inputHandler?.destroy();
|
this.inputHandler?.destroy();
|
||||||
// Clean up blur timeout
|
// Clean up blur timeout
|
||||||
@ -124,32 +115,20 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
clearTimeout(this.blurTimeout);
|
clearTimeout(this.blurTimeout);
|
||||||
this.blurTimeout = null;
|
this.blurTimeout = null;
|
||||||
}
|
}
|
||||||
// Clean up selection change timeout
|
|
||||||
if (this.selectionChangeTimeout) {
|
|
||||||
clearTimeout(this.selectionChangeTimeout);
|
|
||||||
this.selectionChangeTimeout = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async firstUpdated() {
|
async firstUpdated() {
|
||||||
this.updateValue();
|
this.updateValue();
|
||||||
this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
this.editorContentRef = this.shadowRoot!.querySelector('.editor-content') as HTMLDivElement;
|
||||||
|
|
||||||
// Add global selection listener
|
// We now rely on block-level selection detection
|
||||||
console.log('Adding selectionchange listener');
|
// No global selection listener needed
|
||||||
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);
|
console.log('Received block-text-selected event:', e.detail);
|
||||||
|
|
||||||
if (!this.slashMenu.visible) {
|
if (!this.slashMenu.visible && e.detail.hasSelection && e.detail.text.length > 0) {
|
||||||
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
|
// Use the rect from the event if available
|
||||||
@ -159,19 +138,36 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
y: Math.max(45, e.detail.rect.top - 45)
|
y: Math.max(45, e.detail.rect.top - 45)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('Showing formatting menu at:', coords);
|
||||||
|
|
||||||
// Show the formatting menu at the calculated position
|
// Show the formatting menu at the calculated position
|
||||||
this.formattingMenu.show(
|
this.formattingMenu.show(
|
||||||
coords,
|
coords,
|
||||||
async (command: string) => await this.applyFormat(command)
|
async (command: string) => await this.applyFormat(command)
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
this.updateFormattingMenuPosition();
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// Clear selection
|
});
|
||||||
|
|
||||||
|
// Hide formatting menu when clicking outside
|
||||||
|
document.addEventListener('mousedown', (e) => {
|
||||||
|
// Check if click is on the formatting menu itself
|
||||||
|
const formattingMenuElement = this.formattingMenu.shadowRoot?.querySelector('.formatting-menu');
|
||||||
|
if (formattingMenuElement && formattingMenuElement.contains(e.target as Node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have an active selection
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.toString().trim().length > 0) {
|
||||||
|
// Don't hide if we still have a selection
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the menu
|
||||||
|
if (this.formattingMenu.visible) {
|
||||||
this.hideFormattingMenu();
|
this.hideFormattingMenu();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add global keyboard listener for undo/redo
|
// Add global keyboard listener for undo/redo
|
||||||
@ -727,133 +723,6 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
|
|||||||
// Let the block component handle selection via custom event
|
// Let the block component handle selection via custom event
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleSelectionChange(): void {
|
|
||||||
console.log('=== handleSelectionChange called ===');
|
|
||||||
|
|
||||||
const selection = window.getSelection();
|
|
||||||
if (!selection || selection.rangeCount === 0) {
|
|
||||||
if (this.formattingMenu.visible) {
|
|
||||||
console.log('No selection, hiding menu');
|
|
||||||
this.hideFormattingMenu();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedText = selection.toString().trim();
|
|
||||||
console.log('Selected text:', selectedText);
|
|
||||||
|
|
||||||
if (selectedText.length === 0) {
|
|
||||||
if (this.formattingMenu.visible) {
|
|
||||||
console.log('No text selected, hiding menu');
|
|
||||||
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 {
|
||||||
|
@ -349,8 +349,10 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
console.log('Cursor position after mouseup:', pos);
|
console.log('Cursor position after mouseup:', pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for text selection
|
// Check for text selection with a longer delay
|
||||||
|
setTimeout(() => {
|
||||||
this.checkForTextSelection();
|
this.checkForTextSelection();
|
||||||
|
}, 50);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
this.handleMouseUp(e);
|
this.handleMouseUp(e);
|
||||||
@ -368,6 +370,31 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add select event listener
|
||||||
|
editableBlock.addEventListener('selectstart', () => {
|
||||||
|
console.log('Selection started in block');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for selection changes with a mutation observer
|
||||||
|
let selectionCheckTimeout: any = null;
|
||||||
|
const checkSelectionDebounced = () => {
|
||||||
|
if (selectionCheckTimeout) clearTimeout(selectionCheckTimeout);
|
||||||
|
selectionCheckTimeout = setTimeout(() => {
|
||||||
|
this.checkForTextSelection();
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check selection on various events
|
||||||
|
editableBlock.addEventListener('mouseup', checkSelectionDebounced);
|
||||||
|
editableBlock.addEventListener('keyup', checkSelectionDebounced);
|
||||||
|
document.addEventListener('selectionchange', () => {
|
||||||
|
// Check if this block has focus
|
||||||
|
if (document.activeElement === editableBlock ||
|
||||||
|
this.shadowRoot?.activeElement === editableBlock) {
|
||||||
|
checkSelectionDebounced();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Set initial content if needed
|
// Set initial content if needed
|
||||||
if (this.block.content) {
|
if (this.block.content) {
|
||||||
if (this.block.type === 'code') {
|
if (this.block.type === 'code') {
|
||||||
@ -798,57 +825,51 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
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) return;
|
if (!editableElement) {
|
||||||
|
console.log('checkForTextSelection: No editable element found');
|
||||||
const selection = window.getSelection();
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedText = selection.toString().trim();
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) {
|
||||||
|
console.log('checkForTextSelection: No selection or range count is 0');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedText = selection.toString();
|
||||||
|
console.log('checkForTextSelection: Selected text raw:', selectedText, 'length:', selectedText.length);
|
||||||
|
|
||||||
// Only proceed if we have selected text
|
// Only proceed if we have selected text
|
||||||
if (selectedText.length === 0) {
|
if (selectedText.trim().length === 0) {
|
||||||
// Dispatch event to clear selection
|
console.log('checkForTextSelection: Selected text is empty after trim');
|
||||||
this.dispatchEvent(new CustomEvent('block-text-selected', {
|
|
||||||
detail: {
|
|
||||||
text: '',
|
|
||||||
blockId: this.block.id,
|
|
||||||
hasSelection: false
|
|
||||||
},
|
|
||||||
bubbles: true,
|
|
||||||
composed: true
|
|
||||||
}));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the selection is within this block
|
// Check if the selection is within this block
|
||||||
const range = selection.getRangeAt(0);
|
const range = selection.getRangeAt(0);
|
||||||
|
console.log('checkForTextSelection: Range:', {
|
||||||
|
startContainer: range.startContainer,
|
||||||
|
endContainer: range.endContainer,
|
||||||
|
collapsed: range.collapsed
|
||||||
|
});
|
||||||
|
|
||||||
// Check if both start and end are within our editable element
|
// Check if both start and end are within our editable element
|
||||||
const startInBlock = editableElement.contains(range.startContainer);
|
const startInBlock = editableElement.contains(range.startContainer);
|
||||||
const endInBlock = editableElement.contains(range.endContainer);
|
const endInBlock = editableElement.contains(range.endContainer);
|
||||||
|
|
||||||
|
console.log('checkForTextSelection: Start in block:', startInBlock, 'End in block:', endInBlock);
|
||||||
|
|
||||||
if (startInBlock && endInBlock) {
|
if (startInBlock && endInBlock) {
|
||||||
console.log('Block detected text selection:', selectedText);
|
console.log('✅ Block detected text selection:', selectedText.trim());
|
||||||
|
|
||||||
// Get the bounding rect of the selection
|
// Get the bounding rect of the selection
|
||||||
const rect = range.getBoundingClientRect();
|
const rect = range.getBoundingClientRect();
|
||||||
|
console.log('Selection rect:', rect);
|
||||||
|
|
||||||
// Dispatch event to parent with selection details
|
// 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.trim(),
|
||||||
blockId: this.block.id,
|
blockId: this.block.id,
|
||||||
range: range,
|
range: range,
|
||||||
rect: rect,
|
rect: rect,
|
||||||
@ -857,6 +878,8 @@ export class DeesWysiwygBlock extends DeesElement {
|
|||||||
bubbles: true,
|
bubbles: true,
|
||||||
composed: true
|
composed: true
|
||||||
}));
|
}));
|
||||||
|
} else {
|
||||||
|
console.log('checkForTextSelection: Selection not contained in block');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
Reference in New Issue
Block a user