From ad3ee65de3557bafa80ba35083035d296731331a Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 6 Feb 2026 13:31:30 -0300 Subject: [PATCH 1/9] merge model --- .../lib/modelApi/editing/mergeModel.ts | 11 +- .../test/modelApi/editing/mergeModelTest.ts | 349 ++++++++++++++++++ 2 files changed, 357 insertions(+), 3 deletions(-) diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts index ab02e56f741c..58ea69125f3b 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts @@ -198,7 +198,8 @@ function mergeTables( if (i == 0 && colIndex + j >= table.rows[0].cells.length) { for (let k = 0; k < table.rows.length; k++) { const leftCell = table.rows[k]?.cells[colIndex + j - 1]; - table.rows[k].cells[colIndex + j] = createTableCell( + const index = leftCell.spanLeft ? colIndex + j + 1 : colIndex + j; + table.rows[k].cells[index] = createTableCell( false /*spanLeft*/, false /*spanAbove*/, leftCell?.isHeader, @@ -218,7 +219,8 @@ function mergeTables( for (let k = 0; k < table.rows[rowIndex].cells.length; k++) { const aboveCell = table.rows[rowIndex + i - 1]?.cells[k]; - table.rows[rowIndex + i].cells[k] = createTableCell( + const index = aboveCell.spanAbove ? rowIndex + i + 1 : rowIndex + i; + table.rows[index].cells[k] = createTableCell( false /*spanLeft*/, false /*spanAbove*/, false /*isHeader*/, @@ -228,7 +230,10 @@ function mergeTables( } const oldCell = table.rows[rowIndex + i].cells[colIndex + j]; - table.rows[rowIndex + i].cells[colIndex + j] = newCell; + const cellIndex = oldCell.spanLeft ? colIndex + j + 1 : colIndex + j; + const index = oldCell.spanAbove ? rowIndex + i + 1 : rowIndex + i; + + table.rows[index].cells[cellIndex] = newCell; if (i == 0 && j == 0) { const newMarker = createSelectionMarker(marker.format); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts index 6dfb579cac5d..faf76a229f38 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts @@ -6041,4 +6041,353 @@ describe('mergeModel', () => { }); // #endregion + + // #region Merge table with spanLeft and spanAbove + + it('table to table, merge table with spanLeft cell', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + + const para1 = createParagraph(); + const text1 = createText('test1'); + const cell01 = createTableCell(false, false, false, { backgroundColor: '01' }); + const cell02 = createTableCell(true, false, false, { backgroundColor: '02' }); // spanLeft + const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); + const cell12 = createTableCell(true, false, false, { backgroundColor: '12' }); // spanLeft + const table1 = createTable(2); + + para1.segments.push(text1); + text1.isSelected = true; + cell12.blocks.push(para1); + table1.rows = [ + { format: {}, height: 0, cells: [cell01, cell02] }, + { format: {}, height: 0, cells: [cell11, cell12] }, + ]; + + majorModel.blocks.push(table1); + + const newPara1 = createParagraph(); + const newText1 = createText('newText1'); + const newCell11 = createTableCell(false, false, false, { backgroundColor: 'n11' }); + const newCell12 = createTableCell(false, false, false, { backgroundColor: 'n12' }); + const newTable1 = createTable(1); + + newPara1.segments.push(newText1); + newCell11.blocks.push(newPara1); + newTable1.rows = [{ format: {}, height: 0, cells: [newCell11, newCell12] }]; + + sourceModel.blocks.push(newTable1); + + spyOn(applyTableFormat, 'applyTableFormat'); + spyOn(normalizeTable, 'normalizeTable'); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeTable: true, + } + ); + + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [marker], + format: {}, + isImplicit: true, + }; + const tableCell: ContentModelTableCell = { + blockGroupType: 'TableCell', + blocks: [paragraph], + format: { + backgroundColor: 'n11', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }; + + expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + marker, + paragraph, + path: [tableCell, majorModel], + tableContext: { + table: majorModel.blocks[0] as ContentModelTable, + rowIndex: 1, + colIndex: 1, + isWholeTableSelected: false, + }, + }); + }); + + it('table to table, merge table with spanAbove cell', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + + const para1 = createParagraph(); + const text1 = createText('test1'); + const cell01 = createTableCell(false, false, false, { backgroundColor: '01' }); + const cell02 = createTableCell(false, false, false, { backgroundColor: '02' }); + const cell11 = createTableCell(false, true, false, { backgroundColor: '11' }); // spanAbove + const cell12 = createTableCell(false, true, false, { backgroundColor: '12' }); // spanAbove + const table1 = createTable(2); + + para1.segments.push(text1); + text1.isSelected = true; + cell12.blocks.push(para1); + table1.rows = [ + { format: {}, height: 0, cells: [cell01, cell02] }, + { format: {}, height: 0, cells: [cell11, cell12] }, + ]; + + majorModel.blocks.push(table1); + + const newPara1 = createParagraph(); + const newText1 = createText('newText1'); + const newCell11 = createTableCell(false, false, false, { backgroundColor: 'n11' }); + const newCell21 = createTableCell(false, false, false, { backgroundColor: 'n21' }); + const newTable1 = createTable(2); + + newPara1.segments.push(newText1); + newCell11.blocks.push(newPara1); + newTable1.rows = [ + { format: {}, height: 0, cells: [newCell11] }, + { format: {}, height: 0, cells: [newCell21] }, + ]; + + sourceModel.blocks.push(newTable1); + + spyOn(applyTableFormat, 'applyTableFormat'); + spyOn(normalizeTable, 'normalizeTable'); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeTable: true, + } + ); + + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [marker], + format: {}, + isImplicit: true, + }; + const tableCell: ContentModelTableCell = { + blockGroupType: 'TableCell', + blocks: [paragraph], + format: { + backgroundColor: 'n11', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }; + + expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + marker, + paragraph, + path: [tableCell, majorModel], + tableContext: { + table: majorModel.blocks[0] as ContentModelTable, + rowIndex: 1, + colIndex: 1, + isWholeTableSelected: false, + }, + }); + }); + + it('table to table, merge table with spanLeft and spanAbove cells requiring expansion', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + + const para1 = createParagraph(); + const text1 = createText('test1'); + const cell01 = createTableCell(false, false, false, { backgroundColor: '01' }); + const cell02 = createTableCell(true, false, false, { backgroundColor: '02' }); // spanLeft + const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); + const cell12 = createTableCell(true, false, false, { backgroundColor: '12' }); // spanLeft + const table1 = createTable(2); + + para1.segments.push(text1); + text1.isSelected = true; + cell12.blocks.push(para1); + table1.rows = [ + { format: {}, height: 0, cells: [cell01, cell02] }, + { format: {}, height: 0, cells: [cell11, cell12] }, + ]; + + majorModel.blocks.push(table1); + + const newPara1 = createParagraph(); + const newText1 = createText('newText1'); + const newCell11 = createTableCell(false, false, false, { backgroundColor: 'n11' }); + const newCell12 = createTableCell(false, false, false, { backgroundColor: 'n12' }); + const newCell21 = createTableCell(false, false, false, { backgroundColor: 'n21' }); + const newCell22 = createTableCell(false, false, false, { backgroundColor: 'n22' }); + const newTable1 = createTable(2); + + newPara1.segments.push(newText1); + newCell11.blocks.push(newPara1); + newTable1.rows = [ + { format: {}, height: 0, cells: [newCell11, newCell12] }, + { format: {}, height: 0, cells: [newCell21, newCell22] }, + ]; + + sourceModel.blocks.push(newTable1); + + spyOn(applyTableFormat, 'applyTableFormat'); + spyOn(normalizeTable, 'normalizeTable'); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeTable: true, + } + ); + + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [marker], + format: {}, + isImplicit: true, + }; + const tableCell: ContentModelTableCell = { + blockGroupType: 'TableCell', + blocks: [paragraph], + format: { + backgroundColor: 'n11', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }; + + expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + marker, + paragraph, + path: [tableCell, majorModel], + tableContext: { + table: majorModel.blocks[0] as ContentModelTable, + rowIndex: 1, + colIndex: 1, + isWholeTableSelected: false, + }, + }); + }); + + it('table to table, merge table with spanAbove cells requiring row expansion', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + + const para1 = createParagraph(); + const text1 = createText('test1'); + const cell01 = createTableCell(false, false, false, { backgroundColor: '01' }); + const cell02 = createTableCell(false, false, false, { backgroundColor: '02' }); + const cell11 = createTableCell(false, true, false, { backgroundColor: '11' }); // spanAbove + const cell12 = createTableCell(false, true, false, { backgroundColor: '12' }); // spanAbove + const table1 = createTable(2); + + para1.segments.push(text1); + text1.isSelected = true; + cell12.blocks.push(para1); + table1.rows = [ + { format: {}, height: 0, cells: [cell01, cell02] }, + { format: {}, height: 0, cells: [cell11, cell12] }, + ]; + + majorModel.blocks.push(table1); + + const newPara1 = createParagraph(); + const newText1 = createText('newText1'); + const newCell11 = createTableCell(false, false, false, { backgroundColor: 'n11' }); + const newCell12 = createTableCell(false, false, false, { backgroundColor: 'n12' }); + const newCell21 = createTableCell(false, false, false, { backgroundColor: 'n21' }); + const newCell22 = createTableCell(false, false, false, { backgroundColor: 'n22' }); + const newTable1 = createTable(2); + + newPara1.segments.push(newText1); + newCell11.blocks.push(newPara1); + newTable1.rows = [ + { format: {}, height: 0, cells: [newCell11, newCell12] }, + { format: {}, height: 0, cells: [newCell21, newCell22] }, + ]; + + sourceModel.blocks.push(newTable1); + + spyOn(applyTableFormat, 'applyTableFormat'); + spyOn(normalizeTable, 'normalizeTable'); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeTable: true, + } + ); + + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [marker], + format: {}, + isImplicit: true, + }; + const tableCell: ContentModelTableCell = { + blockGroupType: 'TableCell', + blocks: [paragraph], + format: { + backgroundColor: 'n11', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }; + + expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + marker, + paragraph, + path: [tableCell, majorModel], + tableContext: { + table: majorModel.blocks[0] as ContentModelTable, + rowIndex: 1, + colIndex: 1, + isWholeTableSelected: false, + }, + }); + }); + + // #endregion }); From 836c98c74777b1f51c60b7ef8ba2c15dfc258fe0 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 6 Feb 2026 19:05:53 -0300 Subject: [PATCH 2/9] textOffset --- .../corePlugin/selection/SelectionPlugin.ts | 17 +- .../selection/SelectionPluginTest.ts | 260 ++++++++++++++++++ 2 files changed, 273 insertions(+), 4 deletions(-) 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..ce106907c4da 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -364,7 +364,14 @@ class SelectionPlugin implements PluginWithState { this.handleSelectionInTable(this.getTabKey(rawEvent)); rawEvent.preventDefault(); } else { - win?.requestAnimationFrame(() => this.handleSelectionInTable(key)); + const textOffset = + selection.range.collapsed && + isNodeOfType(selection.range.startContainer, 'TEXT_NODE') + ? selection.range.startOffset + : undefined; + win?.requestAnimationFrame(() => + this.handleSelectionInTable(key, textOffset) + ); } } } @@ -423,7 +430,8 @@ class SelectionPlugin implements PluginWithState { } private handleSelectionInTable( - key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'TabLeft' | 'TabRight' + key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'TabLeft' | 'TabRight', + textOffset?: number ) { if (!this.editor || !this.state.tableSelection) { return; @@ -470,7 +478,7 @@ class SelectionPlugin implements PluginWithState { if (collapsed && td) { this.setRangeSelectionInTable( td, - key == Up ? td.childNodes.length : 0, + textOffset && (key == Up || key == Down) ? textOffset : 0, this.editor ); } else if (!td && (lastCo.row == -1 || lastCo.row <= parsedTable.length)) { @@ -569,8 +577,9 @@ class SelectionPlugin implements PluginWithState { } else { // Get deepest editable position in the cell const { node, offset } = normalizePos(cell, nodeOffset); + const useTextOffset = isNodeOfType(node, 'TEXT_NODE') && node.length >= nodeOffset; - range.setStart(node, offset); + range.setStart(node, useTextOffset ? nodeOffset : 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..884a665400f1 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -2040,6 +2040,266 @@ describe('SelectionPlugin handle table selection', () => { }); }); + it('From Range with text offset, Press Down - preserves text offset', () => { + 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 collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowDown', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setStartSpy).toHaveBeenCalledWith(td4_text, 1); + }); + + it('From Range with text offset, Press Up - preserves text offset', () => { + 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 collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowUp', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setStartSpy).toHaveBeenCalledWith(td2_text, 1); + }); + + it('From Range with element container (not text node), Press Down - uses offset 0', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td2, + endOffset: 0, + 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 collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowDown', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setStartSpy).toHaveBeenCalledWith(td4_text, 0); + }); + + it('From Range with non-collapsed selection, Press Down - does not preserve offset', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2_text, + startOffset: 0, + endContainer: td2_text, + endOffset: 1, + commonAncestorContainer: tr1, + collapsed: false, + }, + 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 collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowDown', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + }); + + it('From Range with text offset larger than target text length, Press Down - uses normalizePos offset', () => { + // td2_text has length 1 ("2"), we'll use offset 5 which is larger + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2_text, + startOffset: 5, // larger than text length + endContainer: td2_text, + endOffset: 5, + 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 collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowDown', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + // Since textOffset (5) > td4_text.length (1), it should use normalizePos offset (0) + expect(setStartSpy).toHaveBeenCalledWith(td4_text, 0); + }); + it('From Range, Press Down in the last row and move focus outside of table.', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', From 41eac89c46202812ca144c9a5d8330ccd566d727 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 6 Feb 2026 19:21:04 -0300 Subject: [PATCH 3/9] fix --- .../lib/modelApi/editing/mergeModel.ts | 11 +- .../test/modelApi/editing/mergeModelTest.ts | 349 ------------------ 2 files changed, 3 insertions(+), 357 deletions(-) diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts index 58ea69125f3b..ab02e56f741c 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts @@ -198,8 +198,7 @@ function mergeTables( if (i == 0 && colIndex + j >= table.rows[0].cells.length) { for (let k = 0; k < table.rows.length; k++) { const leftCell = table.rows[k]?.cells[colIndex + j - 1]; - const index = leftCell.spanLeft ? colIndex + j + 1 : colIndex + j; - table.rows[k].cells[index] = createTableCell( + table.rows[k].cells[colIndex + j] = createTableCell( false /*spanLeft*/, false /*spanAbove*/, leftCell?.isHeader, @@ -219,8 +218,7 @@ function mergeTables( for (let k = 0; k < table.rows[rowIndex].cells.length; k++) { const aboveCell = table.rows[rowIndex + i - 1]?.cells[k]; - const index = aboveCell.spanAbove ? rowIndex + i + 1 : rowIndex + i; - table.rows[index].cells[k] = createTableCell( + table.rows[rowIndex + i].cells[k] = createTableCell( false /*spanLeft*/, false /*spanAbove*/, false /*isHeader*/, @@ -230,10 +228,7 @@ function mergeTables( } const oldCell = table.rows[rowIndex + i].cells[colIndex + j]; - const cellIndex = oldCell.spanLeft ? colIndex + j + 1 : colIndex + j; - const index = oldCell.spanAbove ? rowIndex + i + 1 : rowIndex + i; - - table.rows[index].cells[cellIndex] = newCell; + table.rows[rowIndex + i].cells[colIndex + j] = newCell; if (i == 0 && j == 0) { const newMarker = createSelectionMarker(marker.format); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts index faf76a229f38..6dfb579cac5d 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts @@ -6041,353 +6041,4 @@ describe('mergeModel', () => { }); // #endregion - - // #region Merge table with spanLeft and spanAbove - - it('table to table, merge table with spanLeft cell', () => { - const majorModel = createContentModelDocument(); - const sourceModel = createContentModelDocument(); - - const para1 = createParagraph(); - const text1 = createText('test1'); - const cell01 = createTableCell(false, false, false, { backgroundColor: '01' }); - const cell02 = createTableCell(true, false, false, { backgroundColor: '02' }); // spanLeft - const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); - const cell12 = createTableCell(true, false, false, { backgroundColor: '12' }); // spanLeft - const table1 = createTable(2); - - para1.segments.push(text1); - text1.isSelected = true; - cell12.blocks.push(para1); - table1.rows = [ - { format: {}, height: 0, cells: [cell01, cell02] }, - { format: {}, height: 0, cells: [cell11, cell12] }, - ]; - - majorModel.blocks.push(table1); - - const newPara1 = createParagraph(); - const newText1 = createText('newText1'); - const newCell11 = createTableCell(false, false, false, { backgroundColor: 'n11' }); - const newCell12 = createTableCell(false, false, false, { backgroundColor: 'n12' }); - const newTable1 = createTable(1); - - newPara1.segments.push(newText1); - newCell11.blocks.push(newPara1); - newTable1.rows = [{ format: {}, height: 0, cells: [newCell11, newCell12] }]; - - sourceModel.blocks.push(newTable1); - - spyOn(applyTableFormat, 'applyTableFormat'); - spyOn(normalizeTable, 'normalizeTable'); - - const result = mergeModel( - majorModel, - sourceModel, - { newEntities: [], deletedEntities: [], newImages: [] }, - { - mergeTable: true, - } - ); - - const marker: ContentModelSelectionMarker = { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }; - const paragraph: ContentModelParagraph = { - blockType: 'Paragraph', - segments: [marker], - format: {}, - isImplicit: true, - }; - const tableCell: ContentModelTableCell = { - blockGroupType: 'TableCell', - blocks: [paragraph], - format: { - backgroundColor: 'n11', - }, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }; - - expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); - expect(result).toEqual({ - marker, - paragraph, - path: [tableCell, majorModel], - tableContext: { - table: majorModel.blocks[0] as ContentModelTable, - rowIndex: 1, - colIndex: 1, - isWholeTableSelected: false, - }, - }); - }); - - it('table to table, merge table with spanAbove cell', () => { - const majorModel = createContentModelDocument(); - const sourceModel = createContentModelDocument(); - - const para1 = createParagraph(); - const text1 = createText('test1'); - const cell01 = createTableCell(false, false, false, { backgroundColor: '01' }); - const cell02 = createTableCell(false, false, false, { backgroundColor: '02' }); - const cell11 = createTableCell(false, true, false, { backgroundColor: '11' }); // spanAbove - const cell12 = createTableCell(false, true, false, { backgroundColor: '12' }); // spanAbove - const table1 = createTable(2); - - para1.segments.push(text1); - text1.isSelected = true; - cell12.blocks.push(para1); - table1.rows = [ - { format: {}, height: 0, cells: [cell01, cell02] }, - { format: {}, height: 0, cells: [cell11, cell12] }, - ]; - - majorModel.blocks.push(table1); - - const newPara1 = createParagraph(); - const newText1 = createText('newText1'); - const newCell11 = createTableCell(false, false, false, { backgroundColor: 'n11' }); - const newCell21 = createTableCell(false, false, false, { backgroundColor: 'n21' }); - const newTable1 = createTable(2); - - newPara1.segments.push(newText1); - newCell11.blocks.push(newPara1); - newTable1.rows = [ - { format: {}, height: 0, cells: [newCell11] }, - { format: {}, height: 0, cells: [newCell21] }, - ]; - - sourceModel.blocks.push(newTable1); - - spyOn(applyTableFormat, 'applyTableFormat'); - spyOn(normalizeTable, 'normalizeTable'); - - const result = mergeModel( - majorModel, - sourceModel, - { newEntities: [], deletedEntities: [], newImages: [] }, - { - mergeTable: true, - } - ); - - const marker: ContentModelSelectionMarker = { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }; - const paragraph: ContentModelParagraph = { - blockType: 'Paragraph', - segments: [marker], - format: {}, - isImplicit: true, - }; - const tableCell: ContentModelTableCell = { - blockGroupType: 'TableCell', - blocks: [paragraph], - format: { - backgroundColor: 'n11', - }, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }; - - expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); - expect(result).toEqual({ - marker, - paragraph, - path: [tableCell, majorModel], - tableContext: { - table: majorModel.blocks[0] as ContentModelTable, - rowIndex: 1, - colIndex: 1, - isWholeTableSelected: false, - }, - }); - }); - - it('table to table, merge table with spanLeft and spanAbove cells requiring expansion', () => { - const majorModel = createContentModelDocument(); - const sourceModel = createContentModelDocument(); - - const para1 = createParagraph(); - const text1 = createText('test1'); - const cell01 = createTableCell(false, false, false, { backgroundColor: '01' }); - const cell02 = createTableCell(true, false, false, { backgroundColor: '02' }); // spanLeft - const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); - const cell12 = createTableCell(true, false, false, { backgroundColor: '12' }); // spanLeft - const table1 = createTable(2); - - para1.segments.push(text1); - text1.isSelected = true; - cell12.blocks.push(para1); - table1.rows = [ - { format: {}, height: 0, cells: [cell01, cell02] }, - { format: {}, height: 0, cells: [cell11, cell12] }, - ]; - - majorModel.blocks.push(table1); - - const newPara1 = createParagraph(); - const newText1 = createText('newText1'); - const newCell11 = createTableCell(false, false, false, { backgroundColor: 'n11' }); - const newCell12 = createTableCell(false, false, false, { backgroundColor: 'n12' }); - const newCell21 = createTableCell(false, false, false, { backgroundColor: 'n21' }); - const newCell22 = createTableCell(false, false, false, { backgroundColor: 'n22' }); - const newTable1 = createTable(2); - - newPara1.segments.push(newText1); - newCell11.blocks.push(newPara1); - newTable1.rows = [ - { format: {}, height: 0, cells: [newCell11, newCell12] }, - { format: {}, height: 0, cells: [newCell21, newCell22] }, - ]; - - sourceModel.blocks.push(newTable1); - - spyOn(applyTableFormat, 'applyTableFormat'); - spyOn(normalizeTable, 'normalizeTable'); - - const result = mergeModel( - majorModel, - sourceModel, - { newEntities: [], deletedEntities: [], newImages: [] }, - { - mergeTable: true, - } - ); - - const marker: ContentModelSelectionMarker = { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }; - const paragraph: ContentModelParagraph = { - blockType: 'Paragraph', - segments: [marker], - format: {}, - isImplicit: true, - }; - const tableCell: ContentModelTableCell = { - blockGroupType: 'TableCell', - blocks: [paragraph], - format: { - backgroundColor: 'n11', - }, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }; - - expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); - expect(result).toEqual({ - marker, - paragraph, - path: [tableCell, majorModel], - tableContext: { - table: majorModel.blocks[0] as ContentModelTable, - rowIndex: 1, - colIndex: 1, - isWholeTableSelected: false, - }, - }); - }); - - it('table to table, merge table with spanAbove cells requiring row expansion', () => { - const majorModel = createContentModelDocument(); - const sourceModel = createContentModelDocument(); - - const para1 = createParagraph(); - const text1 = createText('test1'); - const cell01 = createTableCell(false, false, false, { backgroundColor: '01' }); - const cell02 = createTableCell(false, false, false, { backgroundColor: '02' }); - const cell11 = createTableCell(false, true, false, { backgroundColor: '11' }); // spanAbove - const cell12 = createTableCell(false, true, false, { backgroundColor: '12' }); // spanAbove - const table1 = createTable(2); - - para1.segments.push(text1); - text1.isSelected = true; - cell12.blocks.push(para1); - table1.rows = [ - { format: {}, height: 0, cells: [cell01, cell02] }, - { format: {}, height: 0, cells: [cell11, cell12] }, - ]; - - majorModel.blocks.push(table1); - - const newPara1 = createParagraph(); - const newText1 = createText('newText1'); - const newCell11 = createTableCell(false, false, false, { backgroundColor: 'n11' }); - const newCell12 = createTableCell(false, false, false, { backgroundColor: 'n12' }); - const newCell21 = createTableCell(false, false, false, { backgroundColor: 'n21' }); - const newCell22 = createTableCell(false, false, false, { backgroundColor: 'n22' }); - const newTable1 = createTable(2); - - newPara1.segments.push(newText1); - newCell11.blocks.push(newPara1); - newTable1.rows = [ - { format: {}, height: 0, cells: [newCell11, newCell12] }, - { format: {}, height: 0, cells: [newCell21, newCell22] }, - ]; - - sourceModel.blocks.push(newTable1); - - spyOn(applyTableFormat, 'applyTableFormat'); - spyOn(normalizeTable, 'normalizeTable'); - - const result = mergeModel( - majorModel, - sourceModel, - { newEntities: [], deletedEntities: [], newImages: [] }, - { - mergeTable: true, - } - ); - - const marker: ContentModelSelectionMarker = { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }; - const paragraph: ContentModelParagraph = { - blockType: 'Paragraph', - segments: [marker], - format: {}, - isImplicit: true, - }; - const tableCell: ContentModelTableCell = { - blockGroupType: 'TableCell', - blocks: [paragraph], - format: { - backgroundColor: 'n11', - }, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }; - - expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); - expect(result).toEqual({ - marker, - paragraph, - path: [tableCell, majorModel], - tableContext: { - table: majorModel.blocks[0] as ContentModelTable, - rowIndex: 1, - colIndex: 1, - isWholeTableSelected: false, - }, - }); - }); - - // #endregion }); From 6d40b417ce3141002751e4f5e225a810cfd5ebc7 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 6 Feb 2026 19:22:33 -0300 Subject: [PATCH 4/9] fixes --- .../test/corePlugin/selection/SelectionPluginTest.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 884a665400f1..b21ae4f55c6a 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -2296,8 +2296,10 @@ describe('SelectionPlugin handle table selection', () => { expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); - // Since textOffset (5) > td4_text.length (1), it should use normalizePos offset (0) - expect(setStartSpy).toHaveBeenCalledWith(td4_text, 0); + // Since textOffset (5) > td4_text.length (1), it should use normalizePos offset + // normalizePos(td4, 5) clamps offset to 1 (childNodes.length), then traverses to lastChild + // and sets offset to td4_text.length (1) + expect(setStartSpy).toHaveBeenCalledWith(td4_text, 1); }); it('From Range, Press Down in the last row and move focus outside of table.', () => { From 8c153ea824f0e760af134e17d48185dbc6a61a81 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 9 Feb 2026 17:03:06 -0300 Subject: [PATCH 5/9] calculate offset --- .../corePlugin/selection/SelectionPlugin.ts | 55 ++++-- .../selection/findPositionByTextOffset.ts | 36 ++++ .../lib/corePlugin/selection/getTextOffset.ts | 35 ++++ .../selection/SelectionPluginTest.ts | 147 ++++++++++++++++ .../selection/findPositionByTextOffsetTest.ts | 151 ++++++++++++++++ .../corePlugin/selection/getTextOffsetTest.ts | 162 ++++++++++++++++++ 6 files changed, 571 insertions(+), 15 deletions(-) create mode 100644 packages/roosterjs-content-model-core/lib/corePlugin/selection/findPositionByTextOffset.ts create mode 100644 packages/roosterjs-content-model-core/lib/corePlugin/selection/getTextOffset.ts create mode 100644 packages/roosterjs-content-model-core/test/corePlugin/selection/findPositionByTextOffsetTest.ts create mode 100644 packages/roosterjs-content-model-core/test/corePlugin/selection/getTextOffsetTest.ts 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 ce106907c4da..2c9183c52c83 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -1,5 +1,7 @@ import { findCoordinate } from './findCoordinate'; +import { findPositionByTextOffset } from './findPositionByTextOffset'; import { findTableCellElement } from '../../coreApi/setDOMSelection/findTableCellElement'; +import { getTextOffset } from './getTextOffset'; import { isSingleImageInSelection } from './isSingleImageInSelection'; import { normalizePos } from './normalizePos'; import { @@ -364,13 +366,8 @@ class SelectionPlugin implements PluginWithState { this.handleSelectionInTable(this.getTabKey(rawEvent)); rawEvent.preventDefault(); } else { - const textOffset = - selection.range.collapsed && - isNodeOfType(selection.range.startContainer, 'TEXT_NODE') - ? selection.range.startOffset - : undefined; win?.requestAnimationFrame(() => - this.handleSelectionInTable(key, textOffset) + this.handleSelectionInTable(key, selection.range) ); } } @@ -431,7 +428,7 @@ class SelectionPlugin implements PluginWithState { private handleSelectionInTable( key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'TabLeft' | 'TabRight', - textOffset?: number + selectionBeforeChange?: Range ) { if (!this.editor || !this.state.tableSelection) { return; @@ -476,10 +473,20 @@ class SelectionPlugin implements PluginWithState { } if (collapsed && td) { + const textOffset = selectionBeforeChange + ? getTextOffset( + this.editor.getDocument(), + selectionBeforeChange, + domHelper + ) + : 0; + this.setRangeSelectionInTable( td, - textOffset && (key == Up || key == Down) ? textOffset : 0, - this.editor + 0, + this.editor, + false /* selectAll */, + key == Up || key == Down ? textOffset : 0 ); } else if (!td && (lastCo.row == -1 || lastCo.row <= parsedTable.length)) { this.selectBeforeOrAfterElement( @@ -557,9 +564,11 @@ class SelectionPlugin implements PluginWithState { cell: Node, nodeOffset: number, editor: IEditor, - selectAll?: boolean + selectAll?: boolean, + textOffset?: number ) { - 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; @@ -574,12 +583,15 @@ class SelectionPlugin implements PluginWithState { if (range.toString() === '') { range.collapse(true /* toStart */); } + } else if (textOffset && textOffset > 0) { + const firstBlock = this.findFirstBlockElement(cell); + const pos = findPositionByTextOffset(doc, firstBlock ?? cell, textOffset); + range.setStart(pos.node, pos.offset); + range.collapse(true /* toStart */); } else { - // Get deepest editable position in the cell + // Get deepest editable position at the start of the cell const { node, offset } = normalizePos(cell, nodeOffset); - const useTextOffset = isNodeOfType(node, 'TEXT_NODE') && node.length >= nodeOffset; - - range.setStart(node, useTextOffset ? nodeOffset : offset); + range.setStart(node, offset); range.collapse(true /* toStart */); } @@ -593,6 +605,19 @@ class SelectionPlugin implements PluginWithState { ); } + private findFirstBlockElement(cell: Node): Node | undefined { + for (let i = 0; i < cell.childNodes.length; i++) { + const child = cell.childNodes[i]; + if ( + isNodeOfType(child, 'ELEMENT_NODE') && + (isElementOfType(child, 'div') || isElementOfType(child, 'p')) + ) { + return child; + } + } + return undefined; + } + private updateTableSelectionFromKeyboard(rowChange: number, colChange: number) { if (this.state.tableSelection?.lastCo && this.editor) { const { lastCo, parsedTable } = this.state.tableSelection; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/findPositionByTextOffset.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/findPositionByTextOffset.ts new file mode 100644 index 000000000000..27045392292e --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/findPositionByTextOffset.ts @@ -0,0 +1,36 @@ +import { isNodeOfType } from 'roosterjs-content-model-dom'; +import { normalizePos } from './normalizePos'; +import type { DOMInsertPoint } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function findPositionByTextOffset( + doc: Document, + nodeElement: Node, + targetOffset: number +): DOMInsertPoint { + const walker = doc.createTreeWalker(nodeElement, NodeFilter.SHOW_TEXT); + + if (walker) { + let accumulatedLength = 0; + let node: Node | null; + + while ((node = walker.nextNode())) { + if (isNodeOfType(node, 'TEXT_NODE')) { + const nodeLength = node.length; + + if (accumulatedLength + nodeLength >= targetOffset) { + return { node, offset: targetOffset - accumulatedLength }; + } + accumulatedLength += nodeLength; + } + } + + if (isNodeOfType(walker.currentNode, 'TEXT_NODE')) { + return { node: walker.currentNode, offset: walker.currentNode.length }; + } + } + + return normalizePos(nodeElement, 0); +} diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/getTextOffset.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/getTextOffset.ts new file mode 100644 index 000000000000..e8604e17dd52 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/getTextOffset.ts @@ -0,0 +1,35 @@ +import { isNodeOfType } from 'roosterjs-content-model-dom'; +import type { DOMHelper } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function getTextOffset( + doc: Document, + range: Range, + domHelper: DOMHelper +): number | undefined { + if (!isNodeOfType(range.startContainer, 'TEXT_NODE')) { + return undefined; + } + + const blockElement = domHelper.findClosestBlockElement(range.startContainer); + + let textLength = 0; + const walker = doc.createTreeWalker(blockElement, NodeFilter.SHOW_TEXT); + + if (walker) { + let node: Node | null; + while ((node = walker.nextNode())) { + if (node === range.startContainer) { + return textLength + range.startOffset; + } + + if (isNodeOfType(node, 'TEXT_NODE')) { + textLength += node.length; + } + } + } + + return textLength + range.startOffset; +} 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 b21ae4f55c6a..55b21dcec045 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -737,6 +737,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 +748,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, @@ -2302,6 +2309,146 @@ describe('SelectionPlugin handle table selection', () => { expect(setStartSpy).toHaveBeenCalledWith(td4_text, 1); }); + it('From Range with formatted text (bold), Press Down - preserves text offset across formatting', () => { + // Setup: td2 contains hello (cursor at position 3, which is in "llo" text node) + td2.innerHTML = ''; + const bold = document.createElement('b'); + const boldText = document.createTextNode('he'); + const plainText = document.createTextNode('llo'); + bold.appendChild(boldText); + td2.appendChild(bold); + td2.appendChild(plainText); + + // Setup: td4 contains world + td4.innerHTML = ''; + const italic = document.createElement('i'); + const italicText = document.createTextNode('wor'); + const plainText4 = document.createTextNode('ld'); + italic.appendChild(italicText); + td4.appendChild(italic); + td4.appendChild(plainText4); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: plainText, + startOffset: 1, // cursor at "l|lo" which is text offset 3 ("he" + "l") + endContainer: plainText, + 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 collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowDown', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + // Text offset 3 in td4 should be in italicText at offset 3 ("wor|") + expect(setStartSpy).toHaveBeenCalledWith(italicText, 3); + }); + + it('From Range with nested formatted text, Press Up - preserves text offset', () => { + // Setup: td4 contains hello (cursor at position 4, in "lo" text node) + td4.innerHTML = ''; + const bold = document.createElement('b'); + const italic = document.createElement('i'); + const nestedText = document.createTextNode('hel'); + const plainText4 = document.createTextNode('lo'); + italic.appendChild(nestedText); + bold.appendChild(italic); + td4.appendChild(bold); + td4.appendChild(plainText4); + + // Setup: td2 contains plain text "world" + td2.innerHTML = ''; + const td2Text = document.createTextNode('world'); + td2.appendChild(td2Text); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: plainText4, + startOffset: 1, // cursor at "l|o" which is text offset 4 ("hel" + "l") + endContainer: plainText4, + 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 collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowUp', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + // Text offset 4 in td2 should be "worl|d" at offset 4 + expect(setStartSpy).toHaveBeenCalledWith(td2Text, 4); + }); + it('From Range, Press Down in the last row and move focus outside of table.', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', diff --git a/packages/roosterjs-content-model-core/test/corePlugin/selection/findPositionByTextOffsetTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/findPositionByTextOffsetTest.ts new file mode 100644 index 000000000000..c2d4cce68515 --- /dev/null +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/findPositionByTextOffsetTest.ts @@ -0,0 +1,151 @@ +import { findPositionByTextOffset } from '../../../lib/corePlugin/selection/findPositionByTextOffset'; + +describe('findPositionByTextOffset', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + it('should find position at start of single text node', () => { + container.innerHTML = 'hello'; + const textNode = container.firstChild!; + + const result = findPositionByTextOffset(document, container, 0); + + expect(result.node).toBe(textNode); + expect(result.offset).toBe(0); + }); + + it('should find position in middle of single text node', () => { + container.innerHTML = 'hello'; + const textNode = container.firstChild!; + + const result = findPositionByTextOffset(document, container, 3); + + expect(result.node).toBe(textNode); + expect(result.offset).toBe(3); + }); + + it('should find position at end of single text node', () => { + container.innerHTML = 'hello'; + const textNode = container.firstChild!; + + const result = findPositionByTextOffset(document, container, 5); + + expect(result.node).toBe(textNode); + expect(result.offset).toBe(5); + }); + + it('should find position in first text node when multiple text nodes exist', () => { + // Creates: "hel" + "lo" + container.innerHTML = 'hello'; + const firstTextNode = container.firstChild!; + + const result = findPositionByTextOffset(document, container, 2); + + expect(result.node).toBe(firstTextNode); + expect(result.offset).toBe(2); + }); + + it('should find position in second text node when offset crosses boundary', () => { + // Creates: "hel" + "lo" + container.innerHTML = 'hello'; + const boldElement = container.querySelector('b')!; + const secondTextNode = boldElement.firstChild!; + + const result = findPositionByTextOffset(document, container, 4); + + expect(result.node).toBe(secondTextNode); + expect(result.offset).toBe(1); + }); + + it('should find position at boundary between text nodes', () => { + // Creates: "hel" + "lo" + container.innerHTML = 'hello'; + const firstTextNode = container.firstChild!; + + const result = findPositionByTextOffset(document, container, 3); + + // At boundary, position is at end of first text node (offset 3) + expect(result.node).toBe(firstTextNode); + expect(result.offset).toBe(3); + }); + + it('should handle deeply nested elements', () => { + // Creates:
"hello"
+ container.innerHTML = '
hello
'; + const textNode = container.querySelector('b')!.firstChild!; + + const result = findPositionByTextOffset(document, container, 3); + + expect(result.node).toBe(textNode); + expect(result.offset).toBe(3); + }); + + it('should handle multiple formatted segments', () => { + // Creates: "hel""lo"" world" + container.innerHTML = 'hello world'; + const italicTextNode = container.querySelector('i')!.firstChild!; + + const result = findPositionByTextOffset(document, container, 4); + + expect(result.node).toBe(italicTextNode); + expect(result.offset).toBe(1); + }); + + it('should position at end of last text node when offset exceeds total length', () => { + container.innerHTML = 'hello'; + const textNode = container.firstChild!; + + const result = findPositionByTextOffset(document, container, 100); + + expect(result.node).toBe(textNode); + expect(result.offset).toBe(5); + }); + + it('should handle empty container by using normalizePos fallback', () => { + container.innerHTML = ''; + + const result = findPositionByTextOffset(document, container, 5); + + expect(result.node).toBe(container); + expect(result.offset).toBe(0); + }); + + it('should handle container with only element children (no text)', () => { + container.innerHTML = '

'; + + const result = findPositionByTextOffset(document, container, 5); + + expect(result.node).toBe(container); + expect(result.offset).toBe(0); + }); + + it('should work with paragraph elements', () => { + container.innerHTML = '

first line

second line

'; + const firstPara = container.querySelector('p')!; + const textNode = firstPara.firstChild!; + + const result = findPositionByTextOffset(document, firstPara, 5); + + expect(result.node).toBe(textNode); + expect(result.offset).toBe(5); + }); + + it('should handle mixed content with images', () => { + // Creates: "before" + + "after" + container.innerHTML = 'beforeafter'; + const afterTextNode = container.lastChild!; + + const result = findPositionByTextOffset(document, container, 8); + + expect(result.node).toBe(afterTextNode); + expect(result.offset).toBe(2); + }); +}); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/selection/getTextOffsetTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/getTextOffsetTest.ts new file mode 100644 index 000000000000..dd36b66a2a97 --- /dev/null +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/getTextOffsetTest.ts @@ -0,0 +1,162 @@ +import { createDOMHelper } from '../../../lib/editor/core/DOMHelperImpl'; +import { getTextOffset } from '../../../lib/corePlugin/selection/getTextOffset'; + +describe('getTextOffset', () => { + let container: HTMLDivElement; + let domHelper: ReturnType; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + domHelper = createDOMHelper(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + function createRange(node: Node, offset: number): Range { + const range = document.createRange(); + range.setStart(node, offset); + range.collapse(true); + return range; + } + + it('should return undefined when startContainer is not a text node', () => { + container.innerHTML = '
hello
'; + const divElement = container.firstChild!; + const range = createRange(divElement, 0); + + const result = getTextOffset(document, range, domHelper); + + expect(result).toBeUndefined(); + }); + + it('should return offset at start of single text node', () => { + container.innerHTML = '
hello
'; + const textNode = container.querySelector('div')!.firstChild!; + const range = createRange(textNode, 0); + + const result = getTextOffset(document, range, domHelper); + + expect(result).toBe(0); + }); + + it('should return offset in middle of single text node', () => { + container.innerHTML = '
hello
'; + const textNode = container.querySelector('div')!.firstChild!; + const range = createRange(textNode, 3); + + const result = getTextOffset(document, range, domHelper); + + expect(result).toBe(3); + }); + + it('should return offset at end of single text node', () => { + container.innerHTML = '
hello
'; + const textNode = container.querySelector('div')!.firstChild!; + const range = createRange(textNode, 5); + + const result = getTextOffset(document, range, domHelper); + + expect(result).toBe(5); + }); + + it('should calculate offset across formatted text (bold)', () => { + // Creates:
"hel" + "lo"
+ container.innerHTML = '
hello
'; + const boldTextNode = container.querySelector('b')!.firstChild!; + const range = createRange(boldTextNode, 1); + + const result = getTextOffset(document, range, domHelper); + + // "hel" (3) + "l" (1) = 4 + expect(result).toBe(4); + }); + + it('should calculate offset in first text node when multiple exist', () => { + container.innerHTML = '
hello
'; + const firstTextNode = container.querySelector('div')!.firstChild!; + const range = createRange(firstTextNode, 2); + + const result = getTextOffset(document, range, domHelper); + + expect(result).toBe(2); + }); + + it('should handle deeply nested formatted text', () => { + // Creates:
"hello"
+ container.innerHTML = '
hello
'; + const textNode = container.querySelector('i')!.firstChild!; + const range = createRange(textNode, 3); + + const result = getTextOffset(document, range, domHelper); + + expect(result).toBe(3); + }); + + it('should calculate offset with multiple formatted segments', () => { + // Creates:
"hel""lo"" world"
+ container.innerHTML = '
hello world
'; + const underlineTextNode = container.querySelector('u')!.firstChild!; + const range = createRange(underlineTextNode, 3); + + const result = getTextOffset(document, range, domHelper); + + // "hel" (3) + "lo" (2) + " wo" (3) = 8 + expect(result).toBe(8); + }); + + it('should work within a paragraph inside a table cell', () => { + container.innerHTML = '
hello world
'; + const textNode = container.querySelector('div')!.firstChild!; + const range = createRange(textNode, 6); + + const result = getTextOffset(document, range, domHelper); + + expect(result).toBe(6); + }); + + it('should calculate offset with mixed text and formatting', () => { + // Creates:
"plain ""bold"" more"
+ container.innerHTML = '
plain bold more
'; + const lastTextNode = container.querySelector('div')!.lastChild!; + const range = createRange(lastTextNode, 3); + + const result = getTextOffset(document, range, domHelper); + + // "plain " (6) + "bold" (4) + " mo" (3) = 13 + expect(result).toBe(13); + }); + + it('should handle text node that is direct child of container', () => { + container.innerHTML = 'direct text'; + const textNode = container.firstChild!; + const range = createRange(textNode, 5); + + const result = getTextOffset(document, range, domHelper); + + expect(result).toBe(5); + }); + + it('should handle empty formatted elements', () => { + container.innerHTML = '
hello
'; + const textNode = container.querySelector('div')!.lastChild!; + const range = createRange(textNode, 2); + + const result = getTextOffset(document, range, domHelper); + + expect(result).toBe(2); + }); + + it('should work with span elements', () => { + container.innerHTML = '
firstsecond
'; + const secondSpanText = container.querySelectorAll('span')[1].firstChild!; + const range = createRange(secondSpanText, 3); + + const result = getTextOffset(document, range, domHelper); + + // "first" (5) + "sec" (3) = 8 + expect(result).toBe(8); + }); +}); From 043f3a829d32ef74ef9cca71625c09f1c62f4693 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 9 Feb 2026 17:07:49 -0300 Subject: [PATCH 6/9] nit --- .../lib/corePlugin/selection/SelectionPlugin.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 2c9183c52c83..9f41b0976342 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -486,7 +486,7 @@ class SelectionPlugin implements PluginWithState { 0, this.editor, false /* selectAll */, - key == Up || key == Down ? textOffset : 0 + key == Up || key == Down ? textOffset : undefined ); } else if (!td && (lastCo.row == -1 || lastCo.row <= parsedTable.length)) { this.selectBeforeOrAfterElement( @@ -583,9 +583,9 @@ class SelectionPlugin implements PluginWithState { if (range.toString() === '') { range.collapse(true /* toStart */); } - } else if (textOffset && textOffset > 0) { + } else if (textOffset) { const firstBlock = this.findFirstBlockElement(cell); - const pos = findPositionByTextOffset(doc, firstBlock ?? cell, textOffset); + const pos = findPositionByTextOffset(doc, firstBlock, textOffset); range.setStart(pos.node, pos.offset); range.collapse(true /* toStart */); } else { @@ -605,7 +605,7 @@ class SelectionPlugin implements PluginWithState { ); } - private findFirstBlockElement(cell: Node): Node | undefined { + private findFirstBlockElement(cell: Node): Node { for (let i = 0; i < cell.childNodes.length; i++) { const child = cell.childNodes[i]; if ( @@ -615,7 +615,7 @@ class SelectionPlugin implements PluginWithState { return child; } } - return undefined; + return cell; } private updateTableSelectionFromKeyboard(rowChange: number, colChange: number) { From be0326695cdc229fb3ce68ed4fe32b9cc390f5b3 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 9 Feb 2026 17:38:47 -0300 Subject: [PATCH 7/9] nit --- .../lib/corePlugin/selection/SelectionPlugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9f41b0976342..3693b002faa4 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -589,7 +589,7 @@ class SelectionPlugin implements PluginWithState { range.setStart(pos.node, pos.offset); range.collapse(true /* toStart */); } else { - // Get deepest editable position at the start of the cell + // Get deepest editable position in the cell const { node, offset } = normalizePos(cell, nodeOffset); range.setStart(node, offset); range.collapse(true /* toStart */); From 8c107c6bbf72ebe45bd4b859f723e63763f45ad7 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 10 Feb 2026 18:23:34 -0300 Subject: [PATCH 8/9] use getNodePositionFromEvent --- .../corePlugin/selection/SelectionPlugin.ts | 87 ++-- .../selection/findPositionByTextOffset.ts | 36 -- .../lib/corePlugin/selection/getTextOffset.ts | 35 -- .../selection/SelectionPluginTest.ts | 426 +----------------- .../selection/findPositionByTextOffsetTest.ts | 151 ------- .../corePlugin/selection/getTextOffsetTest.ts | 162 ------- .../event}/getNodePositionFromEvent.ts | 14 +- .../roosterjs-content-model-dom/lib/index.ts | 1 + .../event/getNodePositionFromEventTest.ts | 239 ++++++++++ .../tableEdit/editors/features/TableMover.ts | 22 +- .../lib/touch/TouchPlugin.ts | 8 +- 11 files changed, 342 insertions(+), 839 deletions(-) delete mode 100644 packages/roosterjs-content-model-core/lib/corePlugin/selection/findPositionByTextOffset.ts delete mode 100644 packages/roosterjs-content-model-core/lib/corePlugin/selection/getTextOffset.ts delete mode 100644 packages/roosterjs-content-model-core/test/corePlugin/selection/findPositionByTextOffsetTest.ts delete mode 100644 packages/roosterjs-content-model-core/test/corePlugin/selection/getTextOffsetTest.ts rename packages/{roosterjs-content-model-plugins/lib/utils => roosterjs-content-model-dom/lib/domUtils/event}/getNodePositionFromEvent.ts (72%) create mode 100644 packages/roosterjs-content-model-dom/test/domUtils/event/getNodePositionFromEventTest.ts 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 3693b002faa4..411250dcac7b 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -1,10 +1,10 @@ import { findCoordinate } from './findCoordinate'; -import { findPositionByTextOffset } from './findPositionByTextOffset'; import { findTableCellElement } from '../../coreApi/setDOMSelection/findTableCellElement'; -import { getTextOffset } from './getTextOffset'; import { isSingleImageInSelection } from './isSingleImageInSelection'; import { normalizePos } from './normalizePos'; import { + getDOMInsertPointRect, + getNodePositionFromEvent, isCharacterValue, isElementOfType, isModifierKey, @@ -428,7 +428,7 @@ class SelectionPlugin implements PluginWithState { private handleSelectionInTable( key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight' | 'TabLeft' | 'TabRight', - selectionBeforeChange?: Range + rangeBeforeChange?: Range ) { if (!this.editor || !this.state.tableSelection) { return; @@ -473,21 +473,30 @@ class SelectionPlugin implements PluginWithState { } if (collapsed && td) { - const textOffset = selectionBeforeChange - ? getTextOffset( - this.editor.getDocument(), - selectionBeforeChange, - domHelper - ) - : 0; - - this.setRangeSelectionInTable( - td, - 0, - this.editor, - false /* selectAll */, - key == Up || key == Down ? textOffset : undefined - ); + 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, @@ -560,12 +569,32 @@ 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, - textOffset?: number + selectAll?: boolean ) { const doc = editor.getDocument(); const range = doc.createRange(); @@ -583,11 +612,6 @@ class SelectionPlugin implements PluginWithState { if (range.toString() === '') { range.collapse(true /* toStart */); } - } else if (textOffset) { - const firstBlock = this.findFirstBlockElement(cell); - const pos = findPositionByTextOffset(doc, firstBlock, textOffset); - range.setStart(pos.node, pos.offset); - range.collapse(true /* toStart */); } else { // Get deepest editable position in the cell const { node, offset } = normalizePos(cell, nodeOffset); @@ -605,19 +629,6 @@ class SelectionPlugin implements PluginWithState { ); } - private findFirstBlockElement(cell: Node): Node { - for (let i = 0; i < cell.childNodes.length; i++) { - const child = cell.childNodes[i]; - if ( - isNodeOfType(child, 'ELEMENT_NODE') && - (isElementOfType(child, 'div') || isElementOfType(child, 'p')) - ) { - return child; - } - } - return cell; - } - private updateTableSelectionFromKeyboard(rowChange: number, colChange: number) { if (this.state.tableSelection?.lastCo && this.editor) { const { lastCo, parsedTable } = this.state.tableSelection; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/findPositionByTextOffset.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/findPositionByTextOffset.ts deleted file mode 100644 index 27045392292e..000000000000 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/findPositionByTextOffset.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { isNodeOfType } from 'roosterjs-content-model-dom'; -import { normalizePos } from './normalizePos'; -import type { DOMInsertPoint } from 'roosterjs-content-model-types'; - -/** - * @internal - */ -export function findPositionByTextOffset( - doc: Document, - nodeElement: Node, - targetOffset: number -): DOMInsertPoint { - const walker = doc.createTreeWalker(nodeElement, NodeFilter.SHOW_TEXT); - - if (walker) { - let accumulatedLength = 0; - let node: Node | null; - - while ((node = walker.nextNode())) { - if (isNodeOfType(node, 'TEXT_NODE')) { - const nodeLength = node.length; - - if (accumulatedLength + nodeLength >= targetOffset) { - return { node, offset: targetOffset - accumulatedLength }; - } - accumulatedLength += nodeLength; - } - } - - if (isNodeOfType(walker.currentNode, 'TEXT_NODE')) { - return { node: walker.currentNode, offset: walker.currentNode.length }; - } - } - - return normalizePos(nodeElement, 0); -} diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/getTextOffset.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/getTextOffset.ts deleted file mode 100644 index e8604e17dd52..000000000000 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/getTextOffset.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { isNodeOfType } from 'roosterjs-content-model-dom'; -import type { DOMHelper } from 'roosterjs-content-model-types'; - -/** - * @internal - */ -export function getTextOffset( - doc: Document, - range: Range, - domHelper: DOMHelper -): number | undefined { - if (!isNodeOfType(range.startContainer, 'TEXT_NODE')) { - return undefined; - } - - const blockElement = domHelper.findClosestBlockElement(range.startContainer); - - let textLength = 0; - const walker = doc.createTreeWalker(blockElement, NodeFilter.SHOW_TEXT); - - if (walker) { - let node: Node | null; - while ((node = walker.nextNode())) { - if (node === range.startContainer) { - return textLength + range.startOffset; - } - - if (isNodeOfType(node, 'TEXT_NODE')) { - textLength += node.length; - } - } - } - - return textLength + range.startOffset; -} 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 55b21dcec045..1de3cbf20b7c 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -2011,10 +2011,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); @@ -2047,408 +2053,6 @@ describe('SelectionPlugin handle table selection', () => { }); }); - it('From Range with text offset, Press Down - preserves text offset', () => { - 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 collapseSpy = jasmine.createSpy('collapse'); - const mockedRange = { - setStart: setStartSpy, - collapse: collapseSpy, - } as any; - - createRangeSpy.and.returnValue(mockedRange); - - plugin.onPluginEvent!({ - eventType: 'keyDown', - rawEvent: { - key: 'ArrowDown', - } as any, - }); - - expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); - expect(setStartSpy).toHaveBeenCalledWith(td4_text, 1); - }); - - it('From Range with text offset, Press Up - preserves text offset', () => { - 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 collapseSpy = jasmine.createSpy('collapse'); - const mockedRange = { - setStart: setStartSpy, - collapse: collapseSpy, - } as any; - - createRangeSpy.and.returnValue(mockedRange); - - plugin.onPluginEvent!({ - eventType: 'keyDown', - rawEvent: { - key: 'ArrowUp', - } as any, - }); - - expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); - expect(setStartSpy).toHaveBeenCalledWith(td2_text, 1); - }); - - it('From Range with element container (not text node), Press Down - uses offset 0', () => { - getDOMSelectionSpy.and.returnValue({ - type: 'range', - range: { - startContainer: td2, - startOffset: 0, - endContainer: td2, - endOffset: 0, - 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 collapseSpy = jasmine.createSpy('collapse'); - const mockedRange = { - setStart: setStartSpy, - collapse: collapseSpy, - } as any; - - createRangeSpy.and.returnValue(mockedRange); - - plugin.onPluginEvent!({ - eventType: 'keyDown', - rawEvent: { - key: 'ArrowDown', - } as any, - }); - - expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); - expect(setStartSpy).toHaveBeenCalledWith(td4_text, 0); - }); - - it('From Range with non-collapsed selection, Press Down - does not preserve offset', () => { - getDOMSelectionSpy.and.returnValue({ - type: 'range', - range: { - startContainer: td2_text, - startOffset: 0, - endContainer: td2_text, - endOffset: 1, - commonAncestorContainer: tr1, - collapsed: false, - }, - 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 collapseSpy = jasmine.createSpy('collapse'); - const mockedRange = { - setStart: setStartSpy, - collapse: collapseSpy, - } as any; - - createRangeSpy.and.returnValue(mockedRange); - - plugin.onPluginEvent!({ - eventType: 'keyDown', - rawEvent: { - key: 'ArrowDown', - } as any, - }); - - expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); - }); - - it('From Range with text offset larger than target text length, Press Down - uses normalizePos offset', () => { - // td2_text has length 1 ("2"), we'll use offset 5 which is larger - getDOMSelectionSpy.and.returnValue({ - type: 'range', - range: { - startContainer: td2_text, - startOffset: 5, // larger than text length - endContainer: td2_text, - endOffset: 5, - 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 collapseSpy = jasmine.createSpy('collapse'); - const mockedRange = { - setStart: setStartSpy, - collapse: collapseSpy, - } as any; - - createRangeSpy.and.returnValue(mockedRange); - - plugin.onPluginEvent!({ - eventType: 'keyDown', - rawEvent: { - key: 'ArrowDown', - } as any, - }); - - expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); - // Since textOffset (5) > td4_text.length (1), it should use normalizePos offset - // normalizePos(td4, 5) clamps offset to 1 (childNodes.length), then traverses to lastChild - // and sets offset to td4_text.length (1) - expect(setStartSpy).toHaveBeenCalledWith(td4_text, 1); - }); - - it('From Range with formatted text (bold), Press Down - preserves text offset across formatting', () => { - // Setup: td2 contains hello (cursor at position 3, which is in "llo" text node) - td2.innerHTML = ''; - const bold = document.createElement('b'); - const boldText = document.createTextNode('he'); - const plainText = document.createTextNode('llo'); - bold.appendChild(boldText); - td2.appendChild(bold); - td2.appendChild(plainText); - - // Setup: td4 contains world - td4.innerHTML = ''; - const italic = document.createElement('i'); - const italicText = document.createTextNode('wor'); - const plainText4 = document.createTextNode('ld'); - italic.appendChild(italicText); - td4.appendChild(italic); - td4.appendChild(plainText4); - - getDOMSelectionSpy.and.returnValue({ - type: 'range', - range: { - startContainer: plainText, - startOffset: 1, // cursor at "l|lo" which is text offset 3 ("he" + "l") - endContainer: plainText, - 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 collapseSpy = jasmine.createSpy('collapse'); - const mockedRange = { - setStart: setStartSpy, - collapse: collapseSpy, - } as any; - - createRangeSpy.and.returnValue(mockedRange); - - plugin.onPluginEvent!({ - eventType: 'keyDown', - rawEvent: { - key: 'ArrowDown', - } as any, - }); - - expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); - // Text offset 3 in td4 should be in italicText at offset 3 ("wor|") - expect(setStartSpy).toHaveBeenCalledWith(italicText, 3); - }); - - it('From Range with nested formatted text, Press Up - preserves text offset', () => { - // Setup: td4 contains hello (cursor at position 4, in "lo" text node) - td4.innerHTML = ''; - const bold = document.createElement('b'); - const italic = document.createElement('i'); - const nestedText = document.createTextNode('hel'); - const plainText4 = document.createTextNode('lo'); - italic.appendChild(nestedText); - bold.appendChild(italic); - td4.appendChild(bold); - td4.appendChild(plainText4); - - // Setup: td2 contains plain text "world" - td2.innerHTML = ''; - const td2Text = document.createTextNode('world'); - td2.appendChild(td2Text); - - getDOMSelectionSpy.and.returnValue({ - type: 'range', - range: { - startContainer: plainText4, - startOffset: 1, // cursor at "l|o" which is text offset 4 ("hel" + "l") - endContainer: plainText4, - 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 collapseSpy = jasmine.createSpy('collapse'); - const mockedRange = { - setStart: setStartSpy, - collapse: collapseSpy, - } as any; - - createRangeSpy.and.returnValue(mockedRange); - - plugin.onPluginEvent!({ - eventType: 'keyDown', - rawEvent: { - key: 'ArrowUp', - } as any, - }); - - expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); - expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); - // Text offset 4 in td2 should be "worl|d" at offset 4 - expect(setStartSpy).toHaveBeenCalledWith(td2Text, 4); - }); - it('From Range, Press Down in the last row and move focus outside of table.', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', @@ -2480,10 +2084,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); @@ -2544,10 +2154,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); @@ -2608,13 +2224,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-core/test/corePlugin/selection/findPositionByTextOffsetTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/findPositionByTextOffsetTest.ts deleted file mode 100644 index c2d4cce68515..000000000000 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/findPositionByTextOffsetTest.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { findPositionByTextOffset } from '../../../lib/corePlugin/selection/findPositionByTextOffset'; - -describe('findPositionByTextOffset', () => { - let container: HTMLDivElement; - - beforeEach(() => { - container = document.createElement('div'); - document.body.appendChild(container); - }); - - afterEach(() => { - document.body.removeChild(container); - }); - - it('should find position at start of single text node', () => { - container.innerHTML = 'hello'; - const textNode = container.firstChild!; - - const result = findPositionByTextOffset(document, container, 0); - - expect(result.node).toBe(textNode); - expect(result.offset).toBe(0); - }); - - it('should find position in middle of single text node', () => { - container.innerHTML = 'hello'; - const textNode = container.firstChild!; - - const result = findPositionByTextOffset(document, container, 3); - - expect(result.node).toBe(textNode); - expect(result.offset).toBe(3); - }); - - it('should find position at end of single text node', () => { - container.innerHTML = 'hello'; - const textNode = container.firstChild!; - - const result = findPositionByTextOffset(document, container, 5); - - expect(result.node).toBe(textNode); - expect(result.offset).toBe(5); - }); - - it('should find position in first text node when multiple text nodes exist', () => { - // Creates: "hel" + "lo" - container.innerHTML = 'hello'; - const firstTextNode = container.firstChild!; - - const result = findPositionByTextOffset(document, container, 2); - - expect(result.node).toBe(firstTextNode); - expect(result.offset).toBe(2); - }); - - it('should find position in second text node when offset crosses boundary', () => { - // Creates: "hel" + "lo" - container.innerHTML = 'hello'; - const boldElement = container.querySelector('b')!; - const secondTextNode = boldElement.firstChild!; - - const result = findPositionByTextOffset(document, container, 4); - - expect(result.node).toBe(secondTextNode); - expect(result.offset).toBe(1); - }); - - it('should find position at boundary between text nodes', () => { - // Creates: "hel" + "lo" - container.innerHTML = 'hello'; - const firstTextNode = container.firstChild!; - - const result = findPositionByTextOffset(document, container, 3); - - // At boundary, position is at end of first text node (offset 3) - expect(result.node).toBe(firstTextNode); - expect(result.offset).toBe(3); - }); - - it('should handle deeply nested elements', () => { - // Creates:
"hello"
- container.innerHTML = '
hello
'; - const textNode = container.querySelector('b')!.firstChild!; - - const result = findPositionByTextOffset(document, container, 3); - - expect(result.node).toBe(textNode); - expect(result.offset).toBe(3); - }); - - it('should handle multiple formatted segments', () => { - // Creates: "hel""lo"" world" - container.innerHTML = 'hello world'; - const italicTextNode = container.querySelector('i')!.firstChild!; - - const result = findPositionByTextOffset(document, container, 4); - - expect(result.node).toBe(italicTextNode); - expect(result.offset).toBe(1); - }); - - it('should position at end of last text node when offset exceeds total length', () => { - container.innerHTML = 'hello'; - const textNode = container.firstChild!; - - const result = findPositionByTextOffset(document, container, 100); - - expect(result.node).toBe(textNode); - expect(result.offset).toBe(5); - }); - - it('should handle empty container by using normalizePos fallback', () => { - container.innerHTML = ''; - - const result = findPositionByTextOffset(document, container, 5); - - expect(result.node).toBe(container); - expect(result.offset).toBe(0); - }); - - it('should handle container with only element children (no text)', () => { - container.innerHTML = '

'; - - const result = findPositionByTextOffset(document, container, 5); - - expect(result.node).toBe(container); - expect(result.offset).toBe(0); - }); - - it('should work with paragraph elements', () => { - container.innerHTML = '

first line

second line

'; - const firstPara = container.querySelector('p')!; - const textNode = firstPara.firstChild!; - - const result = findPositionByTextOffset(document, firstPara, 5); - - expect(result.node).toBe(textNode); - expect(result.offset).toBe(5); - }); - - it('should handle mixed content with images', () => { - // Creates: "before" + + "after" - container.innerHTML = 'beforeafter'; - const afterTextNode = container.lastChild!; - - const result = findPositionByTextOffset(document, container, 8); - - expect(result.node).toBe(afterTextNode); - expect(result.offset).toBe(2); - }); -}); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/selection/getTextOffsetTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/getTextOffsetTest.ts deleted file mode 100644 index dd36b66a2a97..000000000000 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/getTextOffsetTest.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { createDOMHelper } from '../../../lib/editor/core/DOMHelperImpl'; -import { getTextOffset } from '../../../lib/corePlugin/selection/getTextOffset'; - -describe('getTextOffset', () => { - let container: HTMLDivElement; - let domHelper: ReturnType; - - beforeEach(() => { - container = document.createElement('div'); - document.body.appendChild(container); - domHelper = createDOMHelper(container); - }); - - afterEach(() => { - document.body.removeChild(container); - }); - - function createRange(node: Node, offset: number): Range { - const range = document.createRange(); - range.setStart(node, offset); - range.collapse(true); - return range; - } - - it('should return undefined when startContainer is not a text node', () => { - container.innerHTML = '
hello
'; - const divElement = container.firstChild!; - const range = createRange(divElement, 0); - - const result = getTextOffset(document, range, domHelper); - - expect(result).toBeUndefined(); - }); - - it('should return offset at start of single text node', () => { - container.innerHTML = '
hello
'; - const textNode = container.querySelector('div')!.firstChild!; - const range = createRange(textNode, 0); - - const result = getTextOffset(document, range, domHelper); - - expect(result).toBe(0); - }); - - it('should return offset in middle of single text node', () => { - container.innerHTML = '
hello
'; - const textNode = container.querySelector('div')!.firstChild!; - const range = createRange(textNode, 3); - - const result = getTextOffset(document, range, domHelper); - - expect(result).toBe(3); - }); - - it('should return offset at end of single text node', () => { - container.innerHTML = '
hello
'; - const textNode = container.querySelector('div')!.firstChild!; - const range = createRange(textNode, 5); - - const result = getTextOffset(document, range, domHelper); - - expect(result).toBe(5); - }); - - it('should calculate offset across formatted text (bold)', () => { - // Creates:
"hel" + "lo"
- container.innerHTML = '
hello
'; - const boldTextNode = container.querySelector('b')!.firstChild!; - const range = createRange(boldTextNode, 1); - - const result = getTextOffset(document, range, domHelper); - - // "hel" (3) + "l" (1) = 4 - expect(result).toBe(4); - }); - - it('should calculate offset in first text node when multiple exist', () => { - container.innerHTML = '
hello
'; - const firstTextNode = container.querySelector('div')!.firstChild!; - const range = createRange(firstTextNode, 2); - - const result = getTextOffset(document, range, domHelper); - - expect(result).toBe(2); - }); - - it('should handle deeply nested formatted text', () => { - // Creates:
"hello"
- container.innerHTML = '
hello
'; - const textNode = container.querySelector('i')!.firstChild!; - const range = createRange(textNode, 3); - - const result = getTextOffset(document, range, domHelper); - - expect(result).toBe(3); - }); - - it('should calculate offset with multiple formatted segments', () => { - // Creates:
"hel""lo"" world"
- container.innerHTML = '
hello world
'; - const underlineTextNode = container.querySelector('u')!.firstChild!; - const range = createRange(underlineTextNode, 3); - - const result = getTextOffset(document, range, domHelper); - - // "hel" (3) + "lo" (2) + " wo" (3) = 8 - expect(result).toBe(8); - }); - - it('should work within a paragraph inside a table cell', () => { - container.innerHTML = '
hello world
'; - const textNode = container.querySelector('div')!.firstChild!; - const range = createRange(textNode, 6); - - const result = getTextOffset(document, range, domHelper); - - expect(result).toBe(6); - }); - - it('should calculate offset with mixed text and formatting', () => { - // Creates:
"plain ""bold"" more"
- container.innerHTML = '
plain bold more
'; - const lastTextNode = container.querySelector('div')!.lastChild!; - const range = createRange(lastTextNode, 3); - - const result = getTextOffset(document, range, domHelper); - - // "plain " (6) + "bold" (4) + " mo" (3) = 13 - expect(result).toBe(13); - }); - - it('should handle text node that is direct child of container', () => { - container.innerHTML = 'direct text'; - const textNode = container.firstChild!; - const range = createRange(textNode, 5); - - const result = getTextOffset(document, range, domHelper); - - expect(result).toBe(5); - }); - - it('should handle empty formatted elements', () => { - container.innerHTML = '
hello
'; - const textNode = container.querySelector('div')!.lastChild!; - const range = createRange(textNode, 2); - - const result = getTextOffset(document, range, domHelper); - - expect(result).toBe(2); - }); - - it('should work with span elements', () => { - container.innerHTML = '
firstsecond
'; - const secondSpanText = container.querySelectorAll('span')[1].firstChild!; - const range = createRange(secondSpanText, 3); - - const result = getTextOffset(document, range, domHelper); - - // "first" (5) + "sec" (3) = 8 - expect(result).toBe(8); - }); -}); 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 ); From 920de7de2d3a15ca1fc96453479bbe90c1bbef29 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Tue, 10 Feb 2026 19:07:10 -0300 Subject: [PATCH 9/9] selection plugin --- .../selection/SelectionPluginTest.ts | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) 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 1de3cbf20b7c..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'; @@ -2053,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',