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/2] 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 045ce3336ef3a786f80eac4007c87bd3838c3fba Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 23 Mar 2026 19:44:00 -0300 Subject: [PATCH 2/2] borderColor --- .../common/borderColorFormatHandler.ts | 7 +++++-- .../common/borderColorFormatHandlerTest.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/roosterjs-content-model-dom/lib/formatHandlers/common/borderColorFormatHandler.ts b/packages/roosterjs-content-model-dom/lib/formatHandlers/common/borderColorFormatHandler.ts index 0ef75b219d32..40c9c3c1ee2f 100644 --- a/packages/roosterjs-content-model-dom/lib/formatHandlers/common/borderColorFormatHandler.ts +++ b/packages/roosterjs-content-model-dom/lib/formatHandlers/common/borderColorFormatHandler.ts @@ -28,6 +28,8 @@ const BorderStyleKeyMap: { borderLeft: 'border-left-style', }; +const DEFAULT_COLOR = '#000000'; + /** * @internal */ @@ -40,7 +42,8 @@ export const borderColorFormatHandler: FormatHandler = { BorderKeys.forEach(key => { const width = element.style.getPropertyValue(BorderWidthKeyMap[key]); const style = element.style.getPropertyValue(BorderStyleKeyMap[key]); - const borderColor = retrieveElementColor(element, key); + const color = retrieveElementColor(element, key); + const borderColor = color == 'initial' ? DEFAULT_COLOR : color; if (borderColor) { const lightModeColor = getLightModeColor( @@ -67,6 +70,7 @@ export const borderColorFormatHandler: FormatHandler = { const value = format[key]; if (value) { const borderValues = extractBorderValues(value); + const borderColorProperty = BorderColorKeyMap[key]; if (borderValues.color) { const transformedColor = adaptColor( element, @@ -76,7 +80,6 @@ export const borderColorFormatHandler: FormatHandler = { context.darkColorHandler ); if (transformedColor) { - const borderColorProperty = BorderColorKeyMap[key]; element.style.setProperty(borderColorProperty, transformedColor); } } diff --git a/packages/roosterjs-content-model-dom/test/formatHandlers/common/borderColorFormatHandlerTest.ts b/packages/roosterjs-content-model-dom/test/formatHandlers/common/borderColorFormatHandlerTest.ts index e254325dbfc3..1a9eba8116b5 100644 --- a/packages/roosterjs-content-model-dom/test/formatHandlers/common/borderColorFormatHandlerTest.ts +++ b/packages/roosterjs-content-model-dom/test/formatHandlers/common/borderColorFormatHandlerTest.ts @@ -190,6 +190,24 @@ describe('borderColorFormatHandler.parse', () => { ); expect(format.borderTop).toBe('1px solid red'); }); + + it('Parse border with initial color - should use default color', () => { + div.style.borderWidth = '1px'; + div.style.borderStyle = 'solid'; + div.style.borderTopColor = 'initial'; + + context.isDarkMode = false; + context.darkColorHandler = undefined; + + spyOn(colorUtils, 'retrieveElementColor').and.returnValue('initial'); + spyOn(colorUtils, 'getLightModeColor').and.returnValue('#000000'); + + borderColorFormatHandler.parse(format, div, context, {}); + + expect(colorUtils.retrieveElementColor).toHaveBeenCalledWith(div, 'borderTop'); + expect(colorUtils.getLightModeColor).toHaveBeenCalledWith('#000000', false, undefined); + expect(format.borderTop).toBe('1px solid #000000'); + }); }); describe('borderColorFormatHandler.apply', () => {