From d88db98e5ad354c52f0b35e0e542d6cfdc426f5c Mon Sep 17 00:00:00 2001 From: Julia Roldi Date: Mon, 24 Aug 2026 16:57:59 -0300 Subject: [PATCH 1/2] Merge pasted adjacent tables Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../lib/modelApi/editing/mergeModel.ts | 28 +++++- .../test/modelApi/editing/mergeModelTest.ts | 85 +++++++++++++++++++ .../lib/parameter/MergeModelOption.ts | 4 +- 3 files changed, 115 insertions(+), 2 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 10ec74caefcb..e1a756cda809 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts @@ -332,11 +332,37 @@ function mergeTables( normalizeTable(table, markerPosition.marker.format); applyTableFormat(table, undefined /*newFormat*/, true /*keepCellShade*/); - } else { + } else if (!mergeTableAfterPreviousTable(markerPosition, newTable)) { insertBlock(markerPosition, newTable); } } +function mergeTableAfterPreviousTable( + markerPosition: InsertPoint, + newTable: ContentModelTable +): boolean { + const { marker, paragraph, path } = markerPosition; + const parent = path[0]; + const paragraphIndex = parent.blocks.indexOf(paragraph); + const previousBlock = parent.blocks[paragraphIndex - 1]; + + if ( + paragraphIndex > 0 && + paragraph.segments.indexOf(marker) == 0 && + previousBlock?.blockType == 'Table' && + previousBlock.rows[0]?.cells.length > 0 && + newTable.rows[0]?.cells.length > 0 + ) { + const table = mutateBlock(previousBlock); + + table.rows.push(...newTable.rows); + + return true; + } + + return false; +} + function mergeList(markerPosition: InsertPoint, newList: ContentModelListItem) { splitParagraph(markerPosition, newList.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 8cd85f12b250..f5d30475e7ea 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts @@ -4415,6 +4415,91 @@ describe('mergeModel', () => { }); }); + it('Merge table after a table with the same number of columns', () => { + const target = createContentModelDocument(); + const targetTable = createTable(1); + const targetRow = { + cells: [createTableCell(), createTableCell()], + format: {}, + height: 0, + }; + const paragraph = createParagraph(); + const marker = createSelectionMarker(); + + targetTable.rows = [targetRow]; + paragraph.segments.push(marker); + target.blocks.push(targetTable, paragraph); + + const source = createContentModelDocument(); + const sourceTable = createTable(1); + const sourceRow = { + cells: [ + createTableCell(false, false, false, { + borderTop: '1px solid red', + borderRight: '1px solid green', + borderBottom: '1px solid blue', + borderLeft: '1px solid yellow', + }), + createTableCell(), + ], + format: {}, + height: 0, + }; + + sourceTable.rows = [sourceRow]; + source.blocks.push(sourceTable); + + spyOn(applyTableFormat, 'applyTableFormat'); + + const result = mergeModel(target, source, undefined, { + mergeTable: true, + }); + + expect(target.blocks).toEqual([targetTable, paragraph]); + expect(targetTable.rows).toEqual([targetRow, sourceRow]); + expect(sourceRow.cells[0].format).toEqual({ + borderTop: '1px solid red', + borderRight: '1px solid green', + borderBottom: '1px solid blue', + borderLeft: '1px solid yellow', + }); + expect(applyTableFormat.applyTableFormat).not.toHaveBeenCalled(); + expect(result?.marker).toBe(marker); + }); + + it('Merge table after a table with a different number of columns', () => { + const target = createContentModelDocument(); + const targetTable = createTable(1); + const paragraph = createParagraph(); + const targetRow = { + cells: [createTableCell(), createTableCell()], + format: {}, + height: 0, + }; + + targetTable.rows = [targetRow]; + paragraph.segments.push(createSelectionMarker()); + target.blocks.push(targetTable, paragraph); + + const source = createContentModelDocument(); + const sourceTable = createTable(1); + const sourceRow = { + cells: [createTableCell()], + format: {}, + height: 0, + }; + + sourceTable.rows = [sourceRow]; + source.blocks.push(sourceTable); + + mergeModel(target, source, undefined, { + mergeTable: true, + }); + + expect(target.blocks).toEqual([targetTable, paragraph]); + expect(targetTable.rows).toEqual([targetRow, sourceRow]); + }); + // #region preferTarget it('Use customized insert position', () => { diff --git a/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts b/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts index 0ed6c00fb10f..94bb0aee2e90 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts @@ -5,7 +5,9 @@ import type { InsertPoint } from '../selection/InsertPoint'; */ export interface MergeModelOption { /** - * When there is only a table to merge, whether merge this table into current table (if any), or just directly insert (nested table). + * When there is only a table to merge, whether to merge this table into the current table, + * or into the table immediately before the insert position. Otherwise, insert it as a + * separate or nested table. * This is usually used when paste table inside a table * @default false */ From b0e11c82934e5669af5e30b1b37069d22906ba5b Mon Sep 17 00:00:00 2001 From: Julia Roldi Date: Tue, 25 Aug 2026 16:52:51 -0300 Subject: [PATCH 2/2] merge multiple sizes table --- .../lib/modelApi/editing/mergeModel.ts | 29 ++++++++++ .../test/modelApi/editing/mergeModelTest.ts | 55 ++++++++++++++++++- 2 files changed, 82 insertions(+), 2 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 e1a756cda809..87026f33c00e 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts @@ -30,6 +30,7 @@ import type { ReadonlyContentModelDocument, ReadonlyContentModelTable, ShallowMutableContentModelParagraph, + ShallowMutableContentModelTable, } from 'roosterjs-content-model-types'; const HeadingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; @@ -354,6 +355,13 @@ function mergeTableAfterPreviousTable( newTable.rows[0]?.cells.length > 0 ) { const table = mutateBlock(previousBlock); + const columnCount = Math.max( + ...table.rows.map(row => row.cells.length), + ...newTable.rows.map(row => row.cells.length) + ); + + extendTableRows(table, columnCount); + extendTableRows(newTable, columnCount); table.rows.push(...newTable.rows); @@ -363,6 +371,27 @@ function mergeTableAfterPreviousTable( return false; } +function extendTableRows(table: ShallowMutableContentModelTable, columnCount: number) { + table.rows.forEach(row => { + const currentColumnCount = row.cells.length; + const lastCell = row.cells[currentColumnCount - 1]; + + if (lastCell) { + for (let col = currentColumnCount; col < columnCount; col++) { + const spanCell = createTableCell( + true /* spanLeft */, + false /* spanAbove */, + lastCell.isHeader, + lastCell.format, + lastCell.dataset + ); + + row.cells.push(spanCell); + } + } + }); +} + function mergeList(markerPosition: InsertPoint, newList: ContentModelListItem) { splitParagraph(markerPosition, newList.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 f5d30475e7ea..b9386b5eec01 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts @@ -4472,7 +4472,13 @@ describe('mergeModel', () => { const targetTable = createTable(1); const paragraph = createParagraph(); const targetRow = { - cells: [createTableCell(), createTableCell()], + cells: [ + createTableCell(), + createTableCell(), + createTableCell(), + createTableCell(), + createTableCell(), + ], format: {}, height: 0, }; @@ -4484,7 +4490,49 @@ describe('mergeModel', () => { const source = createContentModelDocument(); const sourceTable = createTable(1); const sourceRow = { - cells: [createTableCell()], + cells: [createTableCell(), createTableCell(), createTableCell()], + format: {}, + height: 0, + }; + + sourceTable.rows = [sourceRow]; + source.blocks.push(sourceTable); + + mergeModel(target, source, undefined, { + mergeTable: true, + }); + + expect(target.blocks).toEqual([targetTable, paragraph]); + expect(targetTable.rows).toEqual([targetRow, sourceRow]); + expect(sourceRow.cells.length).toBe(5); + expect(sourceRow.cells[3].spanLeft).toBeTrue(); + expect(sourceRow.cells[4].spanLeft).toBeTrue(); + }); + + it('Merge table after a table with more columns', () => { + const target = createContentModelDocument(); + const targetTable = createTable(1); + const paragraph = createParagraph(); + const targetRow = { + cells: [createTableCell(), createTableCell(), createTableCell()], + format: {}, + height: 0, + }; + + targetTable.rows = [targetRow]; + paragraph.segments.push(createSelectionMarker()); + target.blocks.push(targetTable, paragraph); + + const source = createContentModelDocument(); + const sourceTable = createTable(1); + const sourceRow = { + cells: [ + createTableCell(), + createTableCell(), + createTableCell(), + createTableCell(), + createTableCell(), + ], format: {}, height: 0, }; @@ -4498,6 +4546,9 @@ describe('mergeModel', () => { expect(target.blocks).toEqual([targetTable, paragraph]); expect(targetTable.rows).toEqual([targetRow, sourceRow]); + expect(targetRow.cells.length).toBe(5); + expect(targetRow.cells[3].spanLeft).toBeTrue(); + expect(targetRow.cells[4].spanLeft).toBeTrue(); }); // #region preferTarget