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 ab02e56f741..33cba790a24 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts @@ -27,6 +27,7 @@ import type { ReadonlyContentModelBlock, ReadonlyContentModelBlockGroup, ReadonlyContentModelDocument, + ReadonlyContentModelTable, ShallowMutableContentModelParagraph, } from 'roosterjs-content-model-types'; @@ -191,44 +192,64 @@ function mergeTables( const { table: readonlyTable, colIndex, rowIndex } = tableContext; const table = mutateBlock(readonlyTable); + const newTableColCount = newTable.rows[0]?.cells.length || 0; + const newTableRowCount = newTable.rows.length; + + const lastTargetColIndex = getTargetColIndex(table, rowIndex, colIndex, newTableColCount); + const extraColsNeeded = lastTargetColIndex - table.rows[0].cells.length; + + if (extraColsNeeded > 0) { + const currentColCount = table.rows[0].cells.length; + for (let col = 0; col < extraColsNeeded; col++) { + const newColIndex = currentColCount + col; + for (let k = 0; k < table.rows.length; k++) { + const leftCell = table.rows[k]?.cells[newColIndex - 1]; + table.rows[k].cells[newColIndex] = createTableCell( + false /*spanLeft*/, + false /*spanAbove*/, + leftCell?.isHeader, + leftCell?.format + ); + } + } + } + + const lastTargetRowIndex = getTargetRowIndex(table, rowIndex, newTableRowCount, colIndex); + const extraRowsNeeded = lastTargetRowIndex - table.rows.length; + + if (extraRowsNeeded > 0) { + const currentRowCount = table.rows.length; + const colCount = table.rows[0]?.cells.length || 0; + for (let row = 0; row < extraRowsNeeded; row++) { + const newRowIndex = currentRowCount + row; + table.rows[newRowIndex] = { + cells: [], + format: {}, + height: 0, + }; + for (let k = 0; k < colCount; k++) { + const aboveCell = table.rows[newRowIndex - 1]?.cells[k]; + table.rows[newRowIndex].cells[k] = createTableCell( + false /*spanLeft*/, + false /*spanAbove*/, + false /*isHeader*/, + aboveCell?.format + ); + } + } + } + for (let i = 0; i < newTable.rows.length; i++) { + const targetRowIndex = getTargetRowIndex(table, rowIndex, i, colIndex); + for (let j = 0; j < newTable.rows[i].cells.length; j++) { const newCell = newTable.rows[i].cells[j]; - 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( - false /*spanLeft*/, - false /*spanAbove*/, - leftCell?.isHeader, - leftCell?.format - ); - } - } - - if (j == 0 && rowIndex + i >= table.rows.length) { - if (!table.rows[rowIndex + i]) { - table.rows[rowIndex + i] = { - cells: [], - format: {}, - height: 0, - }; - } + const targetColIndex = getTargetColIndex(table, targetRowIndex, colIndex, j); - 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( - false /*spanLeft*/, - false /*spanAbove*/, - false /*isHeader*/, - aboveCell?.format - ); - } - } + const oldCell = table.rows[targetRowIndex]?.cells[targetColIndex]; - const oldCell = table.rows[rowIndex + i].cells[colIndex + j]; - table.rows[rowIndex + i].cells[colIndex + j] = newCell; + table.rows[targetRowIndex].cells[targetColIndex] = newCell; if (i == 0 && j == 0) { const newMarker = createSelectionMarker(marker.format); @@ -494,6 +515,7 @@ function getFormatWithoutSegmentFormat( KeysOfSegmentFormat.forEach(key => delete resultFormat[key]); return resultFormat; } + function getHyperlinkTextColor(sourceFormat: ContentModelHyperLinkFormat) { const result: ContentModelHyperLinkFormat = {}; if (sourceFormat.textColor) { @@ -502,3 +524,60 @@ function getHyperlinkTextColor(sourceFormat: ContentModelHyperLinkFormat) { return result; } + +function getTargetColIndex( + table: ReadonlyContentModelTable, + rowIndex: number, + startColIndex: number, + offset: number +): number { + const row = table.rows[rowIndex]; + if (!row) { + return startColIndex + offset; + } + + if (offset === 0) { + return startColIndex; + } + + let targetColIndex = startColIndex; + let logicalCellsToSkip = offset; + + while (logicalCellsToSkip > 0) { + targetColIndex++; + + if (targetColIndex >= row.cells.length) { + logicalCellsToSkip--; + } else if (!row.cells[targetColIndex].spanLeft) { + logicalCellsToSkip--; + } + } + + return targetColIndex; +} + +function getTargetRowIndex( + table: ReadonlyContentModelTable, + startRowIndex: number, + offset: number, + colIndex: number +): number { + if (offset === 0) { + return startRowIndex; + } + + let targetRowIndex = startRowIndex; + let logicalRowsToSkip = offset; + + while (logicalRowsToSkip > 0) { + targetRowIndex++; + + if (targetRowIndex >= table.rows.length) { + logicalRowsToSkip--; + } else if (!table.rows[targetRowIndex]?.cells[colIndex]?.spanAbove) { + logicalRowsToSkip--; + } + } + + return targetRowIndex; +} 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 6dfb579cac5..ed1af5dc629 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,264 @@ describe('mergeModel', () => { }); // #endregion + + // #region Merge table with spanLeft and spanAbove + + it('table to table, merge table with spanLeft cell - should skip span cells', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + + // Create a 2x3 table where columns 1-2 are merged (spanLeft) + // Selection is in cell12 (which is spanLeft, part of merged cell01-02) + 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 cell03 = createTableCell(false, false, false, { backgroundColor: '03' }); + const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); + const cell12 = createTableCell(true, false, false, { backgroundColor: '12' }); // spanLeft + const cell13 = createTableCell(false, false, false, { backgroundColor: '13' }); + const table1 = createTable(2); + + para1.segments.push(text1); + text1.isSelected = true; + cell12.blocks.push(para1); + table1.rows = [ + { format: {}, height: 0, cells: [cell01, cell02, cell03] }, + { format: {}, height: 0, cells: [cell11, cell12, cell13] }, + ]; + + majorModel.blocks.push(table1); + + // Source table has 2 cells - they should be placed at positions 1 and 2 (skipping span) + 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'); + + mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeTable: true, + } + ); + + const table = majorModel.blocks[0] as ContentModelTable; + + // The first new cell should replace cell12 (at index 1), second should go to index 2 + expect(table.rows[1].cells[1]).toBe(newCell11); + expect(table.rows[1].cells[2]).toBe(newCell12); + expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); + }); + + it('table to table, merge table with spanAbove cell - should skip span cells', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + + // Create a 3x2 table where rows 0-1 are merged (spanAbove) at column 1 + 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 cell21 = createTableCell(false, false, false, { backgroundColor: '21' }); + const cell22 = createTableCell(false, false, false, { backgroundColor: '22' }); + const table1 = createTable(3); + + 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] }, + { format: {}, height: 0, cells: [cell21, cell22] }, + ]; + + majorModel.blocks.push(table1); + + // Source table has 2 rows - they should be placed at row 1 and 2 (skipping spanAbove) + 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'); + + mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeTable: true, + } + ); + + const table = majorModel.blocks[0] as ContentModelTable; + + // First new cell should replace cell12 at row 1, second should go to row 2 + expect(table.rows[1].cells[1]).toBe(newCell11); + expect(table.rows[2].cells[1]).toBe(newCell21); + expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); + }); + + it('table to table, merge 2x2 table into cell with spanLeft - should expand and skip spans', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + + // Create a 2x3 table where columns 1-2 are merged (spanLeft) + 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 cell03 = createTableCell(false, false, false, { backgroundColor: '03' }); + const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); + const cell12 = createTableCell(true, false, false, { backgroundColor: '12' }); // spanLeft + const cell13 = createTableCell(false, false, false, { backgroundColor: '13' }); + const table1 = createTable(2); + + para1.segments.push(text1); + text1.isSelected = true; + cell12.blocks.push(para1); + table1.rows = [ + { format: {}, height: 0, cells: [cell01, cell02, cell03] }, + { format: {}, height: 0, cells: [cell11, cell12, cell13] }, + ]; + + majorModel.blocks.push(table1); + + // Source table is 2x2 + 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'); + + mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeTable: true, + } + ); + + const table = majorModel.blocks[0] as ContentModelTable; + + // New cells should be placed correctly, skipping the spanLeft cell + expect(table.rows[1].cells[1]).toBe(newCell11); + expect(table.rows[1].cells[2]).toBe(newCell12); + // Second row of pasted table should go to row 2 + expect(table.rows.length).toBe(3); + expect(table.rows[2].cells[1]).toBe(newCell21); + expect(table.rows[2].cells[2]).toBe(newCell22); + expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); + }); + + it('table to table, merge 2x2 table into cell with spanAbove - should expand and skip spans', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + + // Create a 3x2 table where rows 0-1 are merged (spanAbove) at column 1 + 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 cell21 = createTableCell(false, false, false, { backgroundColor: '21' }); + const cell22 = createTableCell(false, false, false, { backgroundColor: '22' }); + const table1 = createTable(3); + + 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] }, + { format: {}, height: 0, cells: [cell21, cell22] }, + ]; + + majorModel.blocks.push(table1); + + // Source table is 2x2 + 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'); + + mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeTable: true, + } + ); + + const table = majorModel.blocks[0] as ContentModelTable; + + // New cells should be placed correctly, skipping the spanAbove cell + // Table should have expanded to 3 columns + expect(table.rows[0].cells.length).toBe(3); + expect(table.rows[1].cells[1]).toBe(newCell11); + expect(table.rows[1].cells[2]).toBe(newCell12); + expect(table.rows[2].cells[1]).toBe(newCell21); + expect(table.rows[2].cells[2]).toBe(newCell22); + expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); + }); + + // #endregion });