From 275552a8638764dee5ab8a9726fead64271fba91 Mon Sep 17 00:00:00 2001 From: Julia Roldi Date: Thu, 10 Sep 2026 13:27:03 -0300 Subject: [PATCH 1/2] Fix borders when merging table cells Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../lib/modelApi/table/mergeTableCells.ts | 16 ++++++ .../modelApi/table/mergeTableCellsTest.ts | 53 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableCells.ts b/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableCells.ts index a732c2262531..16c0e7374557 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableCells.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableCells.ts @@ -12,6 +12,22 @@ export function mergeTableCells(table: ShallowMutableContentModelTable) { sel && canMergeCells(table.rows, sel.firstRow, sel.firstColumn, sel.lastRow, sel.lastColumn) ) { + const firstCell = mutateBlock(table.rows[sel.firstRow].cells[sel.firstColumn]); + const borderRight = table.rows[sel.firstRow].cells[sel.lastColumn].format.borderRight; + const borderBottom = table.rows[sel.lastRow].cells[sel.firstColumn].format.borderBottom; + + if (borderRight) { + firstCell.format.borderRight = borderRight; + } else { + delete firstCell.format.borderRight; + } + + if (borderBottom) { + firstCell.format.borderBottom = borderBottom; + } else { + delete firstCell.format.borderBottom; + } + for (let rowIndex = sel.firstRow; rowIndex <= sel.lastRow; rowIndex++) { for (let colIndex = sel.firstColumn; colIndex <= sel.lastColumn; colIndex++) { const cell = table.rows[rowIndex].cells[colIndex]; diff --git a/packages/roosterjs-content-model-api/test/modelApi/table/mergeTableCellsTest.ts b/packages/roosterjs-content-model-api/test/modelApi/table/mergeTableCellsTest.ts index d5dfb53e659f..6af58dc20fb6 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/table/mergeTableCellsTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/table/mergeTableCellsTest.ts @@ -200,6 +200,59 @@ describe('mergeTableCells', () => { expect(cells.map(c => c.spanAbove)).toEqual([false, false, true, true]); }); + it('preserves the borders that delimit the merged area', () => { + const table = createTable(2); + const cells = [ + createTableCell(false, false, false, { + borderTop: '1px solid top', + borderRight: '1px solid internal-right', + borderBottom: '1px solid internal-bottom', + borderLeft: '1px solid left', + }), + createTableCell(false, false, false, { borderRight: '2px dashed right' }), + createTableCell(false, false, false, { borderBottom: '3px double bottom' }), + createTableCell(false, false, false, {}), + ]; + + table.rows[0].cells.push(cells[0], cells[1]); + table.rows[1].cells.push(cells[2], cells[3]); + cells[0].isSelected = true; + cells[3].isSelected = true; + + mergeTableCells(table); + + expect(cells[0].format).toEqual({ + borderTop: '1px solid top', + borderRight: '2px dashed right', + borderBottom: '3px double bottom', + borderLeft: '1px solid left', + }); + expect(cells.map(c => c.spanLeft)).toEqual([false, true, false, true]); + expect(cells.map(c => c.spanAbove)).toEqual([false, false, true, true]); + }); + + it('does not retain internal borders when the merged outer edges have no borders', () => { + const table = createTable(2); + const cells = [ + createTableCell(false, false, false, { + borderRight: '1px solid internal-right', + borderBottom: '1px solid internal-bottom', + }), + createTableCell(false, false, false, {}), + createTableCell(false, false, false, {}), + createTableCell(false, false, false, {}), + ]; + + table.rows[0].cells.push(cells[0], cells[1]); + table.rows[1].cells.push(cells[2], cells[3]); + cells[0].isSelected = true; + cells[3].isSelected = true; + + mergeTableCells(table); + + expect(cells[0].format).toEqual({}); + }); + it('table with both selection and cached elements', () => { const table = createTable(3); const cells: ContentModelTableCell[] = []; From 0712753e09bf90b29d905f3e2f8dceadfd28740d Mon Sep 17 00:00:00 2001 From: Julia Roldi Date: Thu, 10 Sep 2026 14:58:28 -0300 Subject: [PATCH 2/2] fix --- .../lib/modelApi/table/mergeTableCells.ts | 71 +++++++++++++++---- .../modelApi/table/mergeTableCellsTest.ts | 43 ++++++++++- 2 files changed, 97 insertions(+), 17 deletions(-) diff --git a/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableCells.ts b/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableCells.ts index 16c0e7374557..411976ee4b85 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableCells.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableCells.ts @@ -1,6 +1,10 @@ import { canMergeCells } from './canMergeCells'; import { getSelectedCells, mutateBlock } from 'roosterjs-content-model-dom'; -import type { ShallowMutableContentModelTable } from 'roosterjs-content-model-types'; +import type { + BorderKey, + ContentModelTableCellFormat, + ShallowMutableContentModelTable, +} from 'roosterjs-content-model-types'; /** * @internal @@ -13,20 +17,15 @@ export function mergeTableCells(table: ShallowMutableContentModelTable) { canMergeCells(table.rows, sel.firstRow, sel.firstColumn, sel.lastRow, sel.lastColumn) ) { const firstCell = mutateBlock(table.rows[sel.firstRow].cells[sel.firstColumn]); - const borderRight = table.rows[sel.firstRow].cells[sel.lastColumn].format.borderRight; - const borderBottom = table.rows[sel.lastRow].cells[sel.firstColumn].format.borderBottom; + const borderTop = getHorizontalBorder(table, sel.firstRow, sel, 'borderTop'); + const borderRight = getVerticalBorder(table, sel.lastColumn, sel, 'borderRight'); + const borderBottom = getHorizontalBorder(table, sel.lastRow, sel, 'borderBottom'); + const borderLeft = getVerticalBorder(table, sel.firstColumn, sel, 'borderLeft'); - if (borderRight) { - firstCell.format.borderRight = borderRight; - } else { - delete firstCell.format.borderRight; - } - - if (borderBottom) { - firstCell.format.borderBottom = borderBottom; - } else { - delete firstCell.format.borderBottom; - } + setBorder(firstCell.format, 'borderTop', borderTop); + setBorder(firstCell.format, 'borderRight', borderRight); + setBorder(firstCell.format, 'borderBottom', borderBottom); + setBorder(firstCell.format, 'borderLeft', borderLeft); for (let rowIndex = sel.firstRow; rowIndex <= sel.lastRow; rowIndex++) { for (let colIndex = sel.firstColumn; colIndex <= sel.lastColumn; colIndex++) { @@ -42,3 +41,47 @@ export function mergeTableCells(table: ShallowMutableContentModelTable) { } } } + +function getHorizontalBorder( + table: ShallowMutableContentModelTable, + rowIndex: number, + sel: { firstColumn: number; lastColumn: number }, + borderKey: BorderKey +) { + const border = table.rows[rowIndex].cells[sel.firstColumn].format[borderKey]; + + return border && + table.rows[rowIndex].cells + .slice(sel.firstColumn, sel.lastColumn + 1) + .every(cell => cell.format[borderKey] == border) + ? border + : undefined; +} + +function getVerticalBorder( + table: ShallowMutableContentModelTable, + columnIndex: number, + sel: { firstRow: number; lastRow: number }, + borderKey: BorderKey +) { + const border = table.rows[sel.firstRow].cells[columnIndex].format[borderKey]; + + return border && + table.rows + .slice(sel.firstRow, sel.lastRow + 1) + .every(row => row.cells[columnIndex].format[borderKey] == border) + ? border + : undefined; +} + +function setBorder( + format: ContentModelTableCellFormat, + borderKey: BorderKey, + border: string | undefined +) { + if (border) { + format[borderKey] = border; + } else { + delete format[borderKey]; + } +} diff --git a/packages/roosterjs-content-model-api/test/modelApi/table/mergeTableCellsTest.ts b/packages/roosterjs-content-model-api/test/modelApi/table/mergeTableCellsTest.ts index 6af58dc20fb6..1b692781e6a3 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/table/mergeTableCellsTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/table/mergeTableCellsTest.ts @@ -209,9 +209,18 @@ describe('mergeTableCells', () => { borderBottom: '1px solid internal-bottom', borderLeft: '1px solid left', }), - createTableCell(false, false, false, { borderRight: '2px dashed right' }), - createTableCell(false, false, false, { borderBottom: '3px double bottom' }), - createTableCell(false, false, false, {}), + createTableCell(false, false, false, { + borderTop: '1px solid top', + borderRight: '2px dashed right', + }), + createTableCell(false, false, false, { + borderBottom: '3px double bottom', + borderLeft: '1px solid left', + }), + createTableCell(false, false, false, { + borderRight: '2px dashed right', + borderBottom: '3px double bottom', + }), ]; table.rows[0].cells.push(cells[0], cells[1]); @@ -253,6 +262,34 @@ describe('mergeTableCells', () => { expect(cells[0].format).toEqual({}); }); + it('does not extend partial perimeter borders across the merged area', () => { + const table = createTable(5); + const cells = Array.from({ length: 25 }, () => createTableCell(false, false, false, {})); + + for (let rowIndex = 0; rowIndex < 5; rowIndex++) { + table.rows[rowIndex].cells.push(...cells.slice(rowIndex * 5, rowIndex * 5 + 5)); + } + + cells[5].format = { borderTop: '1px solid top', borderLeft: '1px solid left' }; + cells[6].format = { borderTop: '1px solid top', borderRight: '1px solid right' }; + cells[10].format = { + borderBottom: '1px solid bottom', + borderLeft: '1px solid left', + }; + cells[11].format = { + borderRight: '1px solid right', + borderBottom: '1px solid bottom', + }; + cells[5].isSelected = true; + cells[12].isSelected = true; + + mergeTableCells(table); + + expect(cells[5].format).toEqual({ borderLeft: '1px solid left' }); + expect(cells[7].format).toEqual({}); + expect(cells[12].format).toEqual({}); + }); + it('table with both selection and cached elements', () => { const table = createTable(3); const cells: ContentModelTableCell[] = [];