diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts index 922817090436..411250dcac7b 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -3,6 +3,8 @@ import { findTableCellElement } from '../../coreApi/setDOMSelection/findTableCel import { isSingleImageInSelection } from './isSingleImageInSelection'; import { normalizePos } from './normalizePos'; import { + getDOMInsertPointRect, + getNodePositionFromEvent, isCharacterValue, isElementOfType, isModifierKey, @@ -364,7 +366,9 @@ class SelectionPlugin implements PluginWithState { this.handleSelectionInTable(this.getTabKey(rawEvent)); rawEvent.preventDefault(); } else { - win?.requestAnimationFrame(() => this.handleSelectionInTable(key)); + win?.requestAnimationFrame(() => + this.handleSelectionInTable(key, selection.range) + ); } } } @@ -423,7 +427,8 @@ class SelectionPlugin implements PluginWithState { } private handleSelectionInTable( - key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'TabLeft' | 'TabRight' + key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'TabLeft' | 'TabRight', + rangeBeforeChange?: Range ) { if (!this.editor || !this.state.tableSelection) { return; @@ -468,11 +473,30 @@ class SelectionPlugin implements PluginWithState { } if (collapsed && td) { - this.setRangeSelectionInTable( - td, - key == Up ? td.childNodes.length : 0, - this.editor - ); + const textOffset = + (key == 'ArrowUp' || key == 'ArrowDown') && rangeBeforeChange + ? this.getTextOffset( + this.editor, + rangeBeforeChange, + td, + key == 'ArrowUp' + ) + : null; + if (textOffset) { + this.setRangeSelectionInTable( + textOffset.node, + textOffset.offset, + this.editor, + false /* selectAll */ + ); + } else { + this.setRangeSelectionInTable( + td, + 0, + this.editor, + false /* selectAll */ + ); + } } else if (!td && (lastCo.row == -1 || lastCo.row <= parsedTable.length)) { this.selectBeforeOrAfterElement( this.editor, @@ -545,13 +569,35 @@ class SelectionPlugin implements PluginWithState { } } + private getTextOffset(editor: IEditor, range: Range, td: HTMLElement, isKeyUp: boolean) { + const doc = editor.getDocument(); + const cursorRect = range + ? getDOMInsertPointRect(doc, { + node: range.startContainer, + offset: range.startOffset, + }) + : undefined; + const rect = td?.getBoundingClientRect(); + const textOffset = + cursorRect && rect + ? getNodePositionFromEvent( + doc, + editor.getDOMHelper(), + cursorRect.left, + isKeyUp ? rect.top - 1 : rect.top + 1 + ) + : null; + return textOffset; + } + private setRangeSelectionInTable( cell: Node, nodeOffset: number, editor: IEditor, selectAll?: boolean ) { - const range = editor.getDocument().createRange(); + const doc = editor.getDocument(); + const range = doc.createRange(); if (selectAll && cell.firstChild && cell.lastChild) { const cellStart = cell.firstChild; const cellEnd = cell.lastChild; @@ -569,7 +615,6 @@ class SelectionPlugin implements PluginWithState { } else { // Get deepest editable position in the cell const { node, offset } = normalizePos(cell, nodeOffset); - range.setStart(node, offset); range.collapse(true /* toStart */); } diff --git a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts index 53f37e762297..158b273bd8a1 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -1,4 +1,6 @@ import * as findTableCellElement from '../../../lib/coreApi/setDOMSelection/findTableCellElement'; +import * as getDOMInsertPointRectFile from 'roosterjs-content-model-dom/lib/domUtils/selection/getDOMInsertPointRect'; +import * as getNodePositionFromEventFile from 'roosterjs-content-model-dom/lib/domUtils/event/getNodePositionFromEvent'; import * as isSingleImageInSelection from '../../../lib/corePlugin/selection/isSingleImageInSelection'; import * as parseTableCells from 'roosterjs-content-model-dom/lib/domUtils/table/parseTableCells'; import { createDOMHelper } from '../../../lib/editor/core/DOMHelperImpl'; @@ -737,6 +739,7 @@ describe('SelectionPlugin handle table selection', () => { let getComputedStyleSpy: jasmine.Spy; let addEventListenerSpy: jasmine.Spy; let announceSpy: jasmine.Spy; + let createTreeWalkerSpy: jasmine.Spy; beforeEach(() => { contentDiv = document.createElement('div'); @@ -747,8 +750,14 @@ describe('SelectionPlugin handle table selection', () => { getComputedStyleSpy = jasmine.createSpy('getComputedStyle'); addEventListenerSpy = jasmine.createSpy('addEventListener'); announceSpy = jasmine.createSpy('announce'); + createTreeWalkerSpy = jasmine + .createSpy('createTreeWalker') + .and.callFake((root: Node, whatToShow?: number) => + document.createTreeWalker(root, whatToShow) + ); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ createRange: createRangeSpy, + createTreeWalker: createTreeWalkerSpy, defaultView: { requestAnimationFrame: requestAnimationFrameSpy, getComputedStyle: getComputedStyleSpy, @@ -2004,10 +2013,16 @@ describe('SelectionPlugin handle table selection', () => { }); const setStartSpy = jasmine.createSpy('setStart'); + const setEndSpy = jasmine.createSpy('setEnd'); const collapseSpy = jasmine.createSpy('collapse'); + const getBoundingClientRectSpy = jasmine + .createSpy('getBoundingClientRect') + .and.returnValue({ left: 10, right: 20, top: 10, bottom: 20 }); const mockedRange = { setStart: setStartSpy, + setEnd: setEndSpy, collapse: collapseSpy, + getBoundingClientRect: getBoundingClientRectSpy, } as any; createRangeSpy.and.returnValue(mockedRange); @@ -2040,6 +2055,310 @@ describe('SelectionPlugin handle table selection', () => { }); }); + it('From Range, Press Down - preserves cursor horizontal position', () => { + // Setup: cursor is at position in td2, moving down to td4 + // The test verifies that the position returned by getNodePositionFromEvent is used for setStart + + // Mock getDOMInsertPointRect to return a cursor rect so getNodePositionFromEvent gets called + spyOn(getDOMInsertPointRectFile, 'getDOMInsertPointRect').and.returnValue({ + left: 50, + right: 60, + top: 10, + bottom: 20, + }); + + // Mock getNodePositionFromEvent to return a specific position + const targetNode = td4_text; + const targetOffset = 1; + spyOn(getNodePositionFromEventFile, 'getNodePositionFromEvent').and.returnValue({ + node: targetNode, + offset: targetOffset, + }); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2_text, + startOffset: 1, + endContainer: td2_text, + endOffset: 1, + commonAncestorContainer: tr1, + collapsed: true, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td3, + startOffset: 0, + endContainer: td3, + endOffset: 0, + commonAncestorContainer: tr2, + collapsed: true, + }, + isReverted: false, + }); + + func(); + }); + + const setStartSpy = jasmine.createSpy('setStart'); + const setEndSpy = jasmine.createSpy('setEnd'); + const collapseSpy = jasmine.createSpy('collapse'); + const getBoundingClientRectSpy = jasmine + .createSpy('getBoundingClientRect') + .and.returnValue({ left: 50, right: 60, top: 10, bottom: 20 }); + const mockedRange = { + setStart: setStartSpy, + setEnd: setEndSpy, + collapse: collapseSpy, + getBoundingClientRect: getBoundingClientRectSpy, + startContainer: td2_text, + startOffset: 1, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + // Mock td4's getBoundingClientRect to return cell position + spyOn(td4, 'getBoundingClientRect').and.returnValue({ + left: 40, + right: 100, + top: 30, + bottom: 50, + } as DOMRect); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowDown', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + // Verify that setStart is called with the position returned by getNodePositionFromEvent + expect(setStartSpy).toHaveBeenCalledWith(targetNode, targetOffset); + }); + + it('From Range, Press Up - preserves cursor horizontal position', () => { + // Setup: cursor is at position in td4, moving up to td2 + + // Mock getDOMInsertPointRect to return a cursor rect so getNodePositionFromEvent gets called + spyOn(getDOMInsertPointRectFile, 'getDOMInsertPointRect').and.returnValue({ + left: 50, + right: 60, + top: 30, + bottom: 40, + }); + + // Mock getNodePositionFromEvent to return a specific position + const targetNode = td2_text; + const targetOffset = 1; + spyOn(getNodePositionFromEventFile, 'getNodePositionFromEvent').and.returnValue({ + node: targetNode, + offset: targetOffset, + }); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td4_text, + startOffset: 1, + endContainer: td4_text, + endOffset: 1, + commonAncestorContainer: tr2, + collapsed: true, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td1, + startOffset: 0, + endContainer: td1, + endOffset: 0, + commonAncestorContainer: tr1, + collapsed: true, + }, + isReverted: false, + }); + + func(); + }); + + const setStartSpy = jasmine.createSpy('setStart'); + const setEndSpy = jasmine.createSpy('setEnd'); + const collapseSpy = jasmine.createSpy('collapse'); + const getBoundingClientRectSpy = jasmine + .createSpy('getBoundingClientRect') + .and.returnValue({ left: 50, right: 60, top: 30, bottom: 40 }); + const mockedRange = { + setStart: setStartSpy, + setEnd: setEndSpy, + collapse: collapseSpy, + getBoundingClientRect: getBoundingClientRectSpy, + startContainer: td4_text, + startOffset: 1, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + // Mock td2's getBoundingClientRect + spyOn(td2, 'getBoundingClientRect').and.returnValue({ + left: 40, + right: 100, + top: 5, + bottom: 25, + } as DOMRect); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowUp', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + // Verify that setStart is called with the position returned by getNodePositionFromEvent + expect(setStartSpy).toHaveBeenCalledWith(targetNode, targetOffset); + }); + + it('From Range, Press Down - falls back to offset 0 when getNodePositionFromEvent returns null', () => { + // When getNodePositionFromEvent returns null, fall back to offset 0 + + // Mock getNodePositionFromEvent to return null + const getNodePositionFromEventSpy = spyOn( + getNodePositionFromEventFile, + 'getNodePositionFromEvent' + ).and.returnValue(null); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2_text, + startOffset: 1, + endContainer: td2_text, + endOffset: 1, + commonAncestorContainer: tr1, + collapsed: true, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td3, + startOffset: 0, + endContainer: td3, + endOffset: 0, + commonAncestorContainer: tr2, + collapsed: true, + }, + isReverted: false, + }); + + func(); + }); + + const setStartSpy = jasmine.createSpy('setStart'); + const setEndSpy = jasmine.createSpy('setEnd'); + const collapseSpy = jasmine.createSpy('collapse'); + const getBoundingClientRectSpy = jasmine + .createSpy('getBoundingClientRect') + .and.returnValue({ left: 50, right: 60, top: 10, bottom: 20 }); + const mockedRange = { + setStart: setStartSpy, + setEnd: setEndSpy, + collapse: collapseSpy, + getBoundingClientRect: getBoundingClientRectSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + // Mock td4's getBoundingClientRect to return cell position + spyOn(td4, 'getBoundingClientRect').and.returnValue({ + left: 40, + right: 100, + top: 30, + bottom: 50, + } as DOMRect); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowDown', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(getNodePositionFromEventSpy).toHaveBeenCalled(); + // When getNodePositionFromEvent returns null, fall back to offset 0 + expect(setStartSpy).toHaveBeenCalledWith(td4_text, 0); + }); + + it('From Range, Press Left - does not use cursor position preservation', () => { + // ArrowLeft should NOT use getNodePositionFromEvent, only ArrowUp/ArrowDown do + + // Spy on getNodePositionFromEvent to verify it's not called + const getNodePositionFromEventSpy = spyOn( + getNodePositionFromEventFile, + 'getNodePositionFromEvent' + ); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2_text, + startOffset: 0, + endContainer: td2_text, + endOffset: 0, + commonAncestorContainer: tr1, + collapsed: true, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td1, + startOffset: 0, + endContainer: td1, + endOffset: 0, + commonAncestorContainer: tr1, + collapsed: true, + }, + isReverted: false, + }); + + func(); + }); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowLeft', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + // For ArrowLeft within the same row, getNodePositionFromEvent should NOT be called + expect(getNodePositionFromEventSpy).not.toHaveBeenCalled(); + // setDOMSelection is not called for ArrowLeft within same row - browser handles it + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + }); + it('From Range, Press Down in the last row and move focus outside of table.', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', @@ -2071,10 +2390,16 @@ describe('SelectionPlugin handle table selection', () => { }); const setStartSpy = jasmine.createSpy('setStart'); + const setEndSpy = jasmine.createSpy('setEnd'); const collapseSpy = jasmine.createSpy('collapse'); + const getBoundingClientRectSpy = jasmine + .createSpy('getBoundingClientRect') + .and.returnValue({ left: 10, right: 20, top: 10, bottom: 20 }); const mockedRange = { setStart: setStartSpy, + setEnd: setEndSpy, collapse: collapseSpy, + getBoundingClientRect: getBoundingClientRectSpy, } as any; createRangeSpy.and.returnValue(mockedRange); @@ -2135,10 +2460,16 @@ describe('SelectionPlugin handle table selection', () => { }); const setStartSpy = jasmine.createSpy('setStart'); + const setEndSpy = jasmine.createSpy('setEnd'); const collapseSpy = jasmine.createSpy('collapse'); + const getBoundingClientRectSpy = jasmine + .createSpy('getBoundingClientRect') + .and.returnValue({ left: 10, right: 20, top: 10, bottom: 20 }); const mockedRange = { setStart: setStartSpy, + setEnd: setEndSpy, collapse: collapseSpy, + getBoundingClientRect: getBoundingClientRectSpy, } as any; createRangeSpy.and.returnValue(mockedRange); @@ -2199,13 +2530,19 @@ describe('SelectionPlugin handle table selection', () => { }); const setStartSpy = jasmine.createSpy('setStart'); + const setEndSpy = jasmine.createSpy('setEnd'); const collapseSpy = jasmine.createSpy('collapse'); const selectNodeContentsSpy = jasmine.createSpy('selectNodeContents'); + const getBoundingClientRectSpy = jasmine + .createSpy('getBoundingClientRect') + .and.returnValue({ left: 10, right: 20, top: 10, bottom: 20 }); const mockedRange = { setStart: setStartSpy, + setEnd: setEndSpy, collapse: collapseSpy, selectNodeContents: selectNodeContentsSpy, + getBoundingClientRect: getBoundingClientRectSpy, } as any; const div = document.createElement('div'); diff --git a/packages/roosterjs-content-model-plugins/lib/utils/getNodePositionFromEvent.ts b/packages/roosterjs-content-model-dom/lib/domUtils/event/getNodePositionFromEvent.ts similarity index 72% rename from packages/roosterjs-content-model-plugins/lib/utils/getNodePositionFromEvent.ts rename to packages/roosterjs-content-model-dom/lib/domUtils/event/getNodePositionFromEvent.ts index 7aea55940c7a..61629bc36050 100644 --- a/packages/roosterjs-content-model-plugins/lib/utils/getNodePositionFromEvent.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/event/getNodePositionFromEvent.ts @@ -1,16 +1,18 @@ -import type { DOMInsertPoint, IEditor } from 'roosterjs-content-model-types'; +import type { DOMHelper, DOMInsertPoint } from 'roosterjs-content-model-types'; /** - * @internal Get insertion point from coordinate. + * Get insertion point from coordinate. + * @param doc Parent document object + * @param domHelper The DOM helper of the editor + * @param x The cursor coordinate for the x-axis + * @param y The cursor coordinate for the y-axis */ export function getNodePositionFromEvent( - editor: IEditor, + doc: Document, + domHelper: DOMHelper, x: number, y: number ): DOMInsertPoint | null { - const doc = editor.getDocument(); - const domHelper = editor.getDOMHelper(); - if ('caretPositionFromPoint' in doc) { // Firefox, Chrome, Edge, Safari, Opera const pos = (doc as any).caretPositionFromPoint(x, y); diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index 1b81b754707d..df09746614b0 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -115,6 +115,7 @@ export { getSelectionRootNode } from './domUtils/selection/getSelectionRootNode' export { getDOMInsertPointRect } from './domUtils/selection/getDOMInsertPointRect'; export { trimModelForSelection } from './domUtils/selection/trimModelForSelection'; export { isCharacterValue, isModifierKey, isCursorMovingKey } from './domUtils/event/eventUtils'; +export { getNodePositionFromEvent } from './domUtils/event/getNodePositionFromEvent'; export { combineBorderValue, extractBorderValues } from './domUtils/style/borderValues'; export { isPunctuation, isSpace, normalizeText } from './domUtils/stringUtil'; export { parseTableCells } from './domUtils/table/parseTableCells'; diff --git a/packages/roosterjs-content-model-dom/test/domUtils/event/getNodePositionFromEventTest.ts b/packages/roosterjs-content-model-dom/test/domUtils/event/getNodePositionFromEventTest.ts new file mode 100644 index 000000000000..70f0b07fb18e --- /dev/null +++ b/packages/roosterjs-content-model-dom/test/domUtils/event/getNodePositionFromEventTest.ts @@ -0,0 +1,239 @@ +import { getNodePositionFromEvent } from '../../../lib/domUtils/event/getNodePositionFromEvent'; +import type { DOMHelper } from 'roosterjs-content-model-types'; + +describe('getNodePositionFromEvent', () => { + let mockDoc: Document; + let mockDomHelper: DOMHelper; + let mockNode: Node; + + beforeEach(() => { + mockNode = document.createElement('div'); + mockDomHelper = { + isNodeInEditor: jasmine.createSpy('isNodeInEditor').and.returnValue(true), + } as any; + // Create empty mock - do NOT include methods as undefined, or 'in' check will pass + mockDoc = {} as any; + }); + + describe('caretPositionFromPoint', () => { + it('should return position from caretPositionFromPoint when available and node is in editor', () => { + const mockPos = { offsetNode: mockNode, offset: 5 }; + (mockDoc as any).caretPositionFromPoint = jasmine + .createSpy('caretPositionFromPoint') + .and.returnValue(mockPos); + + const result = getNodePositionFromEvent(mockDoc, mockDomHelper, 100, 200); + + expect((mockDoc as any).caretPositionFromPoint).toHaveBeenCalledWith(100, 200); + expect(mockDomHelper.isNodeInEditor).toHaveBeenCalledWith(mockNode); + expect(result).toEqual({ node: mockNode, offset: 5 }); + }); + + it('should not use caretPositionFromPoint result when node is not in editor', () => { + const outsideNode = document.createElement('span'); + const mockPos = { offsetNode: outsideNode, offset: 3 }; + (mockDoc as any).caretPositionFromPoint = jasmine + .createSpy('caretPositionFromPoint') + .and.returnValue(mockPos); + (mockDomHelper.isNodeInEditor as jasmine.Spy).and.returnValue(false); + + const result = getNodePositionFromEvent(mockDoc, mockDomHelper, 100, 200); + + expect((mockDoc as any).caretPositionFromPoint).toHaveBeenCalledWith(100, 200); + expect(mockDomHelper.isNodeInEditor).toHaveBeenCalledWith(outsideNode); + expect(result).toBeNull(); + }); + + it('should return null when caretPositionFromPoint returns null', () => { + (mockDoc as any).caretPositionFromPoint = jasmine + .createSpy('caretPositionFromPoint') + .and.returnValue(null); + + const result = getNodePositionFromEvent(mockDoc, mockDomHelper, 100, 200); + + expect((mockDoc as any).caretPositionFromPoint).toHaveBeenCalledWith(100, 200); + expect(result).toBeNull(); + }); + }); + + describe('caretRangeFromPoint fallback', () => { + it('should fall back to caretRangeFromPoint when caretPositionFromPoint is not available', () => { + const mockRange = { startContainer: mockNode, startOffset: 7 }; + mockDoc.caretRangeFromPoint = jasmine + .createSpy('caretRangeFromPoint') + .and.returnValue(mockRange); + + const result = getNodePositionFromEvent(mockDoc, mockDomHelper, 150, 250); + + expect(mockDoc.caretRangeFromPoint).toHaveBeenCalledWith(150, 250); + expect(mockDomHelper.isNodeInEditor).toHaveBeenCalledWith(mockNode); + expect(result).toEqual({ node: mockNode, offset: 7 }); + }); + + it('should fall back to caretRangeFromPoint when caretPositionFromPoint returns node not in editor', () => { + const outsideNode = document.createElement('span'); + const mockPos = { offsetNode: outsideNode, offset: 3 }; + (mockDoc as any).caretPositionFromPoint = jasmine + .createSpy('caretPositionFromPoint') + .and.returnValue(mockPos); + + const mockRange = { startContainer: mockNode, startOffset: 7 }; + mockDoc.caretRangeFromPoint = jasmine + .createSpy('caretRangeFromPoint') + .and.returnValue(mockRange); + + (mockDomHelper.isNodeInEditor as jasmine.Spy).and.callFake( + (node: Node) => node === mockNode + ); + + const result = getNodePositionFromEvent(mockDoc, mockDomHelper, 150, 250); + + expect((mockDoc as any).caretPositionFromPoint).toHaveBeenCalledWith(150, 250); + expect(mockDoc.caretRangeFromPoint).toHaveBeenCalledWith(150, 250); + expect(result).toEqual({ node: mockNode, offset: 7 }); + }); + + it('should not use caretRangeFromPoint result when node is not in editor', () => { + const outsideNode = document.createElement('span'); + const mockRange = { startContainer: outsideNode, startOffset: 2 }; + mockDoc.caretRangeFromPoint = jasmine + .createSpy('caretRangeFromPoint') + .and.returnValue(mockRange); + (mockDomHelper.isNodeInEditor as jasmine.Spy).and.returnValue(false); + + const result = getNodePositionFromEvent(mockDoc, mockDomHelper, 150, 250); + + expect(mockDoc.caretRangeFromPoint).toHaveBeenCalledWith(150, 250); + expect(mockDomHelper.isNodeInEditor).toHaveBeenCalledWith(outsideNode); + expect(result).toBeNull(); + }); + + it('should return null when caretRangeFromPoint returns null', () => { + mockDoc.caretRangeFromPoint = jasmine + .createSpy('caretRangeFromPoint') + .and.returnValue(null); + + const result = getNodePositionFromEvent(mockDoc, mockDomHelper, 150, 250); + + expect(mockDoc.caretRangeFromPoint).toHaveBeenCalledWith(150, 250); + expect(result).toBeNull(); + }); + }); + + describe('elementFromPoint fallback', () => { + it('should fall back to elementFromPoint when other methods are not available', () => { + const mockElement = document.createElement('p'); + mockDoc.elementFromPoint = jasmine + .createSpy('elementFromPoint') + .and.returnValue(mockElement); + (mockDomHelper.isNodeInEditor as jasmine.Spy).and.returnValue(true); + + const result = getNodePositionFromEvent(mockDoc, mockDomHelper, 200, 300); + + expect(mockDoc.elementFromPoint).toHaveBeenCalledWith(200, 300); + expect(mockDomHelper.isNodeInEditor).toHaveBeenCalledWith(mockElement); + expect(result).toEqual({ node: mockElement, offset: 0 }); + }); + + it('should fall back to elementFromPoint when caretRangeFromPoint returns node not in editor', () => { + const outsideNode = document.createElement('span'); + const mockRange = { startContainer: outsideNode, startOffset: 2 }; + mockDoc.caretRangeFromPoint = jasmine + .createSpy('caretRangeFromPoint') + .and.returnValue(mockRange); + + const mockElement = document.createElement('p'); + mockDoc.elementFromPoint = jasmine + .createSpy('elementFromPoint') + .and.returnValue(mockElement); + + (mockDomHelper.isNodeInEditor as jasmine.Spy).and.callFake( + (node: Node) => node === mockElement + ); + + const result = getNodePositionFromEvent(mockDoc, mockDomHelper, 200, 300); + + expect(mockDoc.caretRangeFromPoint).toHaveBeenCalledWith(200, 300); + expect(mockDoc.elementFromPoint).toHaveBeenCalledWith(200, 300); + expect(result).toEqual({ node: mockElement, offset: 0 }); + }); + + it('should not use elementFromPoint result when element is not in editor', () => { + const outsideElement = document.createElement('div'); + mockDoc.elementFromPoint = jasmine + .createSpy('elementFromPoint') + .and.returnValue(outsideElement); + (mockDomHelper.isNodeInEditor as jasmine.Spy).and.returnValue(false); + + const result = getNodePositionFromEvent(mockDoc, mockDomHelper, 200, 300); + + expect(mockDoc.elementFromPoint).toHaveBeenCalledWith(200, 300); + expect(mockDomHelper.isNodeInEditor).toHaveBeenCalledWith(outsideElement); + expect(result).toBeNull(); + }); + + it('should return null when elementFromPoint returns null', () => { + mockDoc.elementFromPoint = jasmine.createSpy('elementFromPoint').and.returnValue(null); + + const result = getNodePositionFromEvent(mockDoc, mockDomHelper, 200, 300); + + expect(mockDoc.elementFromPoint).toHaveBeenCalledWith(200, 300); + expect(result).toBeNull(); + }); + }); + + describe('no methods available', () => { + it('should return null when no positioning methods are available', () => { + const result = getNodePositionFromEvent(mockDoc, mockDomHelper, 100, 100); + + expect(result).toBeNull(); + }); + }); + + describe('priority order', () => { + it('should prefer caretPositionFromPoint over caretRangeFromPoint', () => { + const posNode = document.createElement('div'); + const rangeNode = document.createElement('span'); + + const mockPos = { offsetNode: posNode, offset: 1 }; + (mockDoc as any).caretPositionFromPoint = jasmine + .createSpy('caretPositionFromPoint') + .and.returnValue(mockPos); + + const mockRange = { startContainer: rangeNode, startOffset: 2 }; + mockDoc.caretRangeFromPoint = jasmine + .createSpy('caretRangeFromPoint') + .and.returnValue(mockRange); + + (mockDomHelper.isNodeInEditor as jasmine.Spy).and.returnValue(true); + + const result = getNodePositionFromEvent(mockDoc, mockDomHelper, 100, 100); + + expect((mockDoc as any).caretPositionFromPoint).toHaveBeenCalled(); + expect(mockDoc.caretRangeFromPoint).not.toHaveBeenCalled(); + expect(result).toEqual({ node: posNode, offset: 1 }); + }); + + it('should prefer caretRangeFromPoint over elementFromPoint', () => { + const rangeNode = document.createElement('span'); + const elementNode = document.createElement('p'); + + const mockRange = { startContainer: rangeNode, startOffset: 3 }; + mockDoc.caretRangeFromPoint = jasmine + .createSpy('caretRangeFromPoint') + .and.returnValue(mockRange); + + mockDoc.elementFromPoint = jasmine + .createSpy('elementFromPoint') + .and.returnValue(elementNode); + + (mockDomHelper.isNodeInEditor as jasmine.Spy).and.returnValue(true); + + const result = getNodePositionFromEvent(mockDoc, mockDomHelper, 100, 100); + + expect(mockDoc.caretRangeFromPoint).toHaveBeenCalled(); + expect(mockDoc.elementFromPoint).not.toHaveBeenCalled(); + expect(result).toEqual({ node: rangeNode, offset: 3 }); + }); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts index 7092ad80412e..ec8e7f5a7f56 100644 --- a/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts +++ b/packages/roosterjs-content-model-plugins/lib/tableEdit/editors/features/TableMover.ts @@ -2,15 +2,12 @@ import { createElement } from '../../../pluginUtils/CreateElement/createElement' import { DragAndDropHelper } from '../../../pluginUtils/DragAndDrop/DragAndDropHelper'; import { formatInsertPointWithContentModel } from 'roosterjs-content-model-api'; import { getCMTableFromTable } from '../utils/getTableFromContentModel'; -import { getNodePositionFromEvent } from '../../../utils/getNodePositionFromEvent'; -import type { TableEditFeature } from './TableEditFeature'; -import type { OnTableEditorCreatedCallback } from '../../OnTableEditorCreatedCallback'; -import type { DragAndDropHandler } from '../../../pluginUtils/DragAndDrop/DragAndDropHandler'; import { cloneModel, createContentModelDocument, createSelectionMarker, getFirstSelectedTable, + getNodePositionFromEvent, isNodeOfType, mergeModel, mutateBlock, @@ -18,6 +15,9 @@ import { setParagraphNotImplicit, setSelection, } from 'roosterjs-content-model-dom'; +import type { TableEditFeature } from './TableEditFeature'; +import type { OnTableEditorCreatedCallback } from '../../OnTableEditorCreatedCallback'; +import type { DragAndDropHandler } from '../../../pluginUtils/DragAndDrop/DragAndDropHandler'; import type { DOMSelection, IEditor, @@ -235,7 +235,12 @@ export function onDragging( tableRect.style.top = `${event.clientY + TABLE_MOVER_LENGTH}px`; tableRect.style.left = `${event.clientX + TABLE_MOVER_LENGTH}px`; - const pos = getNodePositionFromEvent(editor, event.clientX, event.clientY); + const pos = getNodePositionFromEvent( + editor.getDocument(), + editor.getDOMHelper(), + event.clientX, + event.clientY + ); if (pos) { const range = editor.getDocument().createRange(); range.setStart(pos.node, pos.offset); @@ -285,7 +290,12 @@ export function onDragEnd( let insertionSuccess: boolean = false; // Get position to insert table - const insertPosition = getNodePositionFromEvent(editor, event.clientX, event.clientY); + const insertPosition = getNodePositionFromEvent( + editor.getDocument(), + editor.getDOMHelper(), + event.clientX, + event.clientY + ); if (insertPosition) { // Move table to new position formatInsertPointWithContentModel( diff --git a/packages/roosterjs-content-model-plugins/lib/touch/TouchPlugin.ts b/packages/roosterjs-content-model-plugins/lib/touch/TouchPlugin.ts index fef76f97cdf1..ee2ffa0e98f8 100644 --- a/packages/roosterjs-content-model-plugins/lib/touch/TouchPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/touch/TouchPlugin.ts @@ -1,4 +1,4 @@ -import { getNodePositionFromEvent } from '../utils/getNodePositionFromEvent'; +import { getNodePositionFromEvent } from 'roosterjs-content-model-dom'; import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-content-model-types'; const MAX_TOUCH_MOVE_DISTANCE = 6; // the max number of offsets for the touch selection to move @@ -75,7 +75,8 @@ export class TouchPlugin implements EditorPlugin { if (!this.isDblClicked) { this.editor.focus(); const caretPosition = getNodePositionFromEvent( - this.editor, + this.editor.getDocument(), + this.editor.getDOMHelper(), event.rawEvent.x, event.rawEvent.y ); @@ -141,7 +142,8 @@ export class TouchPlugin implements EditorPlugin { this.isDblClicked = true; const caretPosition = getNodePositionFromEvent( - this.editor, + this.editor.getDocument(), + this.editor.getDOMHelper(), event.rawEvent.x, event.rawEvent.y );