diff --git a/demo/scripts/controlsV2/demoButtons/tableEditButtons.ts b/demo/scripts/controlsV2/demoButtons/tableEditButtons.ts index 1a374b2f7e53..764a04d04901 100644 --- a/demo/scripts/controlsV2/demoButtons/tableEditButtons.ts +++ b/demo/scripts/controlsV2/demoButtons/tableEditButtons.ts @@ -11,7 +11,12 @@ import type { TableEditSplitMenuItemStringKey, } from 'roosterjs-react'; -const TableEditOperationMap: Partial> = { +type DemoTableEditMenuItemStringKey = + | TableEditMenuItemStringKey + | 'menuNameTableShiftCellsUp' + | 'menuNameTableShiftCellsLeft'; + +const TableEditOperationMap: Partial> = { menuNameTableInsertAbove: 'insertAbove', menuNameTableInsertBelow: 'insertBelow', menuNameTableInsertLeft: 'insertLeft', @@ -35,6 +40,8 @@ const TableEditOperationMap: Partial { diff --git a/karma.fast.conf.js b/karma.fast.conf.js index 8058715f3911..cb9f6157373b 100644 --- a/karma.fast.conf.js +++ b/karma.fast.conf.js @@ -1,5 +1,7 @@ const argv = require('minimist')(process.argv.slice(2)); const components = argv.components !== true && argv.components; +const testPathPattern = argv.testPathPattern !== true && argv.testPathPattern; +const testNamePattern = argv.testNamePattern !== true && argv.testNamePattern; const runCoverage = typeof argv.coverage !== 'undefined'; const runFirefox = typeof argv.firefox !== 'undefined'; const runChrome = typeof argv.chrome !== 'undefined'; @@ -69,8 +71,13 @@ module.exports = function (config) { plugins, client: { components: components, + testPathPattern: testPathPattern, + testNamePattern: testNamePattern, clearContext: false, captureConsole: true, + jasmine: { + grep: testNamePattern || null, + }, }, browsers: launcher, files: ['tools/karma.test.all.js'], diff --git a/package.json b/package.json index b17553c86e82..a35fd48100d7 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "typedoc-plugin-remove-references": "0.0.5", "typescript": "4.4.4", "url-loader": "4.1.0", - "webpack": "5.94.0", + "webpack": "5.104.1", "webpack-cli": "3.3.11", "webpack-dev-server": "3.10.3" }, diff --git a/packages/roosterjs-content-model-api/lib/modelApi/common/clearModelFormat.ts b/packages/roosterjs-content-model-api/lib/modelApi/common/clearModelFormat.ts index 5e1a551f6d0e..c84202f3e646 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/common/clearModelFormat.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/common/clearModelFormat.ts @@ -1,17 +1,17 @@ import { adjustWordSelection } from '../selection/adjustWordSelection'; import { applyTableFormat, + copyFormat, createFormatContainer, getClosestAncestorBlockGroupIndex, iterateSelections, mutateBlock, mutateSegments, - updateTableCellMetadata, - updateTableMetadata, } from 'roosterjs-content-model-dom'; import type { ContentModelSegmentFormat, ContentModelTable, + ContentModelTableCellFormat, ReadonlyContentModelBlock, ReadonlyContentModelBlockGroup, ReadonlyContentModelDocument, @@ -106,7 +106,6 @@ function createTablesFormat(tablesToClear: [ContentModelTable, boolean][]) { useBorderBox: table.format.useBorderBox, borderCollapse: table.format.borderCollapse, }; - updateTableMetadata(table, () => null); } applyTableFormat(table, undefined /*newFormat*/, true); @@ -139,11 +138,19 @@ function clearTableCellFormat( if (cell.isSelected) { const mutableCell = mutateBlock(cell); - updateTableCellMetadata(mutableCell, () => null); mutableCell.isHeader = false; - mutableCell.format = { - useBorderBox: cell.format.useBorderBox, - }; + const newFormat: ContentModelTableCellFormat = {}; + copyFormat(newFormat, cell.format, [ + 'useBorderBox', + 'verticalAlign', + 'height', + 'width', + 'borderTop', + 'borderBottom', + 'borderLeft', + 'borderRight', + ]); + mutableCell.format = newFormat; } if (!tablesToClear.find(x => x[0] == table)) { diff --git a/packages/roosterjs-content-model-api/lib/modelApi/table/alignTableCell.ts b/packages/roosterjs-content-model-api/lib/modelApi/table/alignTableCell.ts index 28b03d5e13d7..d0faf53a8824 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/table/alignTableCell.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/table/alignTableCell.ts @@ -45,8 +45,16 @@ export function alignTableCellHorizontally( operation: TableCellHorizontalAlignOperation ) { alignTableCellInternal(table, cell => { - cell.format.textAlign = + const alignment = TextAlignValueMap[operation][cell.format.direction == 'rtl' ? 'rtl' : 'ltr']; + cell.format.textAlign = alignment; + for (const block of cell.blocks) { + if (block.blockType === 'Paragraph' && block.format.textAlign) { + delete mutateBlock(block).format.textAlign; + } else if (block.blockType == 'BlockGroup' && block.blockGroupType == 'ListItem') { + mutateBlock(block).format.textAlign = alignment; + } + } }); } @@ -82,12 +90,6 @@ function alignTableCellInternal( if (format) { callback(mutateBlock(cell)); - - cell.blocks.forEach(block => { - if (block.blockType === 'Paragraph' && block.format.textAlign) { - delete mutateBlock(block).format.textAlign; - } - }); } } } diff --git a/packages/roosterjs-content-model-api/lib/modelApi/table/shiftCells.ts b/packages/roosterjs-content-model-api/lib/modelApi/table/shiftCells.ts new file mode 100644 index 000000000000..342b9ede41fc --- /dev/null +++ b/packages/roosterjs-content-model-api/lib/modelApi/table/shiftCells.ts @@ -0,0 +1,49 @@ +import { getSelectedCells, mutateBlock } from 'roosterjs-content-model-dom'; +import type { + ShallowMutableContentModelTable, + TableCellShiftOperation, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function shiftCells(table: ShallowMutableContentModelTable, value: TableCellShiftOperation) { + const sel = getSelectedCells(table); + const rows = table.rows; + if (sel) { + const { lastColumn, lastRow, firstColumn, firstRow } = sel; + if (value == 'shiftCellsLeft') { + const selectionLength = lastColumn - firstColumn + 1; + for (let i = firstRow; i <= lastRow; i++) { + const cellsNumber = rows[i].cells.length; + for (let j = firstColumn; j < cellsNumber; j++) { + const nextCellIndex = j + selectionLength; + if (rows[i].cells[nextCellIndex]) { + mutateBlock(rows[i].cells[j]).blocks = [ + ...rows[i].cells[nextCellIndex].blocks, + ]; + mutateBlock(rows[i].cells[nextCellIndex]).blocks = []; + } else { + mutateBlock(rows[i].cells[j]).blocks = []; + } + } + } + } else { + const selectionLength = lastRow - firstRow + 1; + for (let j = firstColumn; j <= lastColumn; j++) { + const cellsNumber = rows.length; + for (let i = firstRow; i < cellsNumber; i++) { + const nextCellIndex = i + selectionLength; + if (rows[nextCellIndex]?.cells[j]) { + mutateBlock(rows[i].cells[j]).blocks = [ + ...rows[nextCellIndex].cells[j].blocks, + ]; + mutateBlock(rows[nextCellIndex].cells[j]).blocks = []; + } else { + mutateBlock(rows[i].cells[j]).blocks = []; + } + } + } + } + } +} diff --git a/packages/roosterjs-content-model-api/lib/modelApi/table/tableContent.ts b/packages/roosterjs-content-model-api/lib/modelApi/table/tableContent.ts new file mode 100644 index 000000000000..da31918495d6 --- /dev/null +++ b/packages/roosterjs-content-model-api/lib/modelApi/table/tableContent.ts @@ -0,0 +1,87 @@ +import { + createTableCell, + createTableRow, + trimModelForSelection, +} from 'roosterjs-content-model-dom'; +import type { + ContentModelBlock, + ContentModelTable, + ContentModelTableCellFormat, + IEditor, + ReadonlyContentModelTable, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function getSelectedContentForTable(editor: IEditor): ContentModelBlock[][] { + const selectedRows: ContentModelBlock[][] = []; + const selection = editor.getDOMSelection(); + if (selection && (selection?.type !== 'range' || !selection.range.collapsed)) { + const selectedModel = editor.getContentModelCopy('disconnected'); + trimModelForSelection(selectedModel, selection); + + for (const block of selectedModel.blocks) { + if (block.blockType === 'Table') { + extractTableCellsContent(block, selectedRows); + } else { + selectedRows.push([block as ContentModelBlock]); + } + } + } + + return selectedRows; +} + +function extractTableCellsContent( + table: ReadonlyContentModelTable, + selectedRows: ContentModelBlock[][] +) { + for (const row of table.rows) { + const rowBlocks: ContentModelBlock[] = []; + for (const cell of row.cells) { + if (!cell.spanLeft && !cell.spanAbove) { + rowBlocks.push(...(cell.blocks as ContentModelBlock[])); + } + } + if (rowBlocks.length > 0) { + selectedRows.push(rowBlocks); + } + } +} + +/** + * @internal + */ +export function insertTableContent( + table: ContentModelTable, + contentRows: ContentModelBlock[][], + colNumber: number, + customCellFormat?: ContentModelTableCellFormat +) { + let rowIndex = 0; + for (const rowBlocks of contentRows) { + if (!table.rows[rowIndex]) { + const row = createTableRow(); + for (let i = 0; i < colNumber; i++) { + const cell = createTableCell( + undefined /*spanLeftOrColSpan */, + undefined /*spanAboveOrRowSpan */, + undefined /* isHeader */, + customCellFormat + ); + row.cells.push(cell); + } + table.rows.push(row); + } + + let cellIndex = 0; + for (const block of rowBlocks) { + if (cellIndex < table.rows[rowIndex].cells.length) { + table.rows[rowIndex].cells[cellIndex].blocks = [block]; + } + cellIndex++; + } + rowIndex++; + } +} diff --git a/packages/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts b/packages/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts index 807be59727cd..bb70be99b2f3 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts @@ -1,4 +1,5 @@ import { adjustTrailingSpaceSelection } from '../../modelApi/selection/adjustTrailingSpaceSelection'; +import { checkXss } from '../utils/checkXss'; import { matchLink } from '../../modelApi/link/matchLink'; import { addLink, @@ -158,7 +159,3 @@ function applyLinkPrefix(url: string): string { return prefix + url; } - -function checkXss(link: string): string { - return link.match(/s\n*c\n*r\n*i\n*p\n*t\n*:/i) ? '' : link; -} diff --git a/packages/roosterjs-content-model-api/lib/publicApi/table/editTable.ts b/packages/roosterjs-content-model-api/lib/publicApi/table/editTable.ts index 21c1db1753d6..5d1c55c70c3a 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/table/editTable.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/table/editTable.ts @@ -8,6 +8,7 @@ import { insertTableRow } from '../../modelApi/table/insertTableRow'; import { mergeTableCells } from '../../modelApi/table/mergeTableCells'; import { mergeTableColumn } from '../../modelApi/table/mergeTableColumn'; import { mergeTableRow } from '../../modelApi/table/mergeTableRow'; +import { shiftCells } from '../../modelApi/table/shiftCells'; import { splitTableCellHorizontally } from '../../modelApi/table/splitTableCellHorizontally'; import { splitTableCellVertically } from '../../modelApi/table/splitTableCellVertically'; import type { TableOperation, IEditor } from 'roosterjs-content-model-types'; @@ -87,6 +88,11 @@ export function editTable(editor: IEditor, operation: TableOperation) { case 'splitVertically': splitTableCellVertically(tableModel); break; + + case 'shiftCellsUp': + case 'shiftCellsLeft': + shiftCells(tableModel, operation); + break; } }); } diff --git a/packages/roosterjs-content-model-api/lib/publicApi/table/insertTable.ts b/packages/roosterjs-content-model-api/lib/publicApi/table/insertTable.ts index 87b6cbc6af9a..9b5c9354181f 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/table/insertTable.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/table/insertTable.ts @@ -1,5 +1,6 @@ import { adjustTableIndentation } from '../../modelApi/common/adjustIndentation'; import { createTableStructure } from '../../modelApi/table/createTableStructure'; +import { getSelectedContentForTable, insertTableContent } from '../../modelApi/table/tableContent'; import { createContentModelDocument, createSelectionMarker, @@ -38,13 +39,18 @@ export function insertTable( ) { editor.focus(); + const blocks = getSelectedContentForTable(editor); + editor.formatContentModel( (model, context) => { - const insertPosition = deleteSelection(model, [], context).insertPoint; + const deleteSelectionResult = deleteSelection(model, [], context); + const insertPosition = deleteSelectionResult.insertPoint; if (insertPosition) { const doc = createContentModelDocument(); + const table = createTableStructure(doc, columns, rows, customCellFormat); + if (format) { table.format = { ...format }; } @@ -52,11 +58,14 @@ export function insertTable( normalizeTable(table, editor.getPendingFormat() || insertPosition.marker.format); initCellWidth(table); + insertTableContent(table, blocks, columns, customCellFormat); + adjustTableIndentation(insertPosition, table); // Assign default vertical align tableMetadataFormat = tableMetadataFormat || { verticalAlign: 'top' }; applyTableFormat(table, tableMetadataFormat); + mergeModel(model, doc, context, { insertPosition, mergeFormat: 'mergeAll', diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/checkXss.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/checkXss.ts new file mode 100644 index 000000000000..5f192bb4a174 --- /dev/null +++ b/packages/roosterjs-content-model-api/lib/publicApi/utils/checkXss.ts @@ -0,0 +1,10 @@ +/** + * @internal Check if there is XSS attack in the link + * @param link The link to be checked + * @returns The safe link, or empty string if there is XSS attack + * @remarks This function checks for patterns like s\nc\nr\ni\np\nt: to prevent XSS attacks. This may block some valid links, + * but it is necessary for security reasons. We treat the word "script" as safe if there are "/" before it. + */ +export function checkXss(link: string): string { + return link.match(/^[^\/]*s\n*c\n*r\n*i\n*p\n*t\n*:/i) ? '' : link; +} diff --git a/packages/roosterjs-content-model-api/test/modelApi/common/clearModelFormatTest.ts b/packages/roosterjs-content-model-api/test/modelApi/common/clearModelFormatTest.ts index 932cb19fe053..2da502627a25 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/common/clearModelFormatTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/common/clearModelFormatTest.ts @@ -830,7 +830,6 @@ describe('clearModelFormat', () => { { blockGroupType: 'TableCell', format: { - useBorderBox: undefined, borderTop: '1px solid #ABABAB', borderRight: '1px solid #ABABAB', borderBottom: '1px solid #ABABAB', @@ -846,7 +845,6 @@ describe('clearModelFormat', () => { { blockGroupType: 'TableCell', format: { - useBorderBox: undefined, borderTop: '1px solid #ABABAB', borderRight: '1px solid #ABABAB', borderBottom: '1px solid #ABABAB', @@ -870,4 +868,362 @@ describe('clearModelFormat', () => { expect(tables).toEqual([[table, true]]); expect(result).toBeFalse(); }); + + it('Model with selection under table should preserve verticalAlign', () => { + const model = createContentModelDocument(); + const table = createTable(1); + const cell1 = createTableCell(false, false, false, { + backgroundColor: 'green', + verticalAlign: 'middle', + useBorderBox: true, + }); + const cell2 = createTableCell(false, false, false, { + backgroundColor: 'blue', + verticalAlign: 'bottom', + useBorderBox: true, + }); + + table.format.backgroundColor = 'red'; + + cell1.isSelected = true; + cell2.isSelected = true; + + table.rows[0].cells.push(cell1, cell2); + model.blocks.push(table); + + const blocks: any[] = []; + const segments: any[] = []; + const tables: any[] = []; + + const result = clearModelFormat(model, blocks, segments, tables); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + format: { useBorderBox: undefined, borderCollapse: undefined }, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":null}', + }, + widths: [], + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + format: { + useBorderBox: true, + verticalAlign: 'middle', + borderTop: '1px solid #ABABAB', + borderRight: '1px solid #ABABAB', + borderBottom: '1px solid #ABABAB', + borderLeft: '1px solid #ABABAB', + }, + dataset: {}, + blocks: [], + isSelected: true, + spanAbove: false, + spanLeft: false, + isHeader: false, + }, + { + blockGroupType: 'TableCell', + format: { + useBorderBox: true, + verticalAlign: 'bottom', + borderTop: '1px solid #ABABAB', + borderRight: '1px solid #ABABAB', + borderBottom: '1px solid #ABABAB', + borderLeft: '1px solid #ABABAB', + }, + dataset: {}, + blocks: [], + isSelected: true, + spanAbove: false, + spanLeft: false, + isHeader: false, + }, + ], + }, + ], + }, + ], + }); + expect(blocks).toEqual([]); + expect(segments).toEqual([]); + expect(tables).toEqual([[table, true]]); + expect(result).toBeFalse(); + }); + + it('Model with selection under table should preserve height and width', () => { + const model = createContentModelDocument(); + const table = createTable(1); + const cell1 = createTableCell(false, false, false, { + backgroundColor: 'green', + height: '100px', + width: '200px', + borderTop: '2px solid red', + borderBottom: '2px solid red', + borderLeft: '2px solid red', + borderRight: '2px solid red', + }); + + // Set borderOverride so applyTableFormat doesn't overwrite borders + cell1.dataset = { + editingInfo: '{"borderOverride":true}', + }; + + table.format.backgroundColor = 'red'; + + cell1.isSelected = true; + + table.rows[0].cells.push(cell1); + model.blocks.push(table); + + const blocks: any[] = []; + const segments: any[] = []; + const tables: any[] = []; + + const result = clearModelFormat(model, blocks, segments, tables); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + format: { useBorderBox: undefined, borderCollapse: undefined }, + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":null}', + }, + widths: [], + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + format: { + height: '100px', + width: '200px', + borderTop: '2px solid red', + borderRight: '2px solid red', + borderBottom: '2px solid red', + borderLeft: '2px solid red', + }, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + blocks: [], + isSelected: true, + spanAbove: false, + spanLeft: false, + isHeader: false, + }, + ], + }, + ], + }, + ], + }); + expect(blocks).toEqual([]); + expect(segments).toEqual([]); + expect(tables).toEqual([[table, true]]); + expect(result).toBeFalse(); + }); + + it('Model with selection under table should clear textAlign', () => { + const model = createContentModelDocument(); + const table = createTable(1); + const cell1 = createTableCell(false, false, false, { + backgroundColor: 'green', + textAlign: 'center', + verticalAlign: 'middle', + }); + + // Set vAlignOverride so applyTableFormat doesn't overwrite verticalAlign + cell1.dataset = { + editingInfo: '{"vAlignOverride":true}', + }; + + table.format.backgroundColor = 'red'; + + cell1.isSelected = true; + + table.rows[0].cells.push(cell1); + model.blocks.push(table); + + const blocks: any[] = []; + const segments: any[] = []; + const tables: any[] = []; + + const result = clearModelFormat(model, blocks, segments, tables); + + // textAlign should be cleared, verticalAlign should be preserved + const resultTable = model.blocks[0] as any; + expect(resultTable.rows[0].cells[0].format.textAlign).toBeUndefined(); + expect(resultTable.rows[0].cells[0].format.verticalAlign).toBe('middle'); + + expect(blocks).toEqual([]); + expect(segments).toEqual([]); + expect(tables).toEqual([[table, true]]); + expect(result).toBeFalse(); + }); + + it('Model with selection under table should preserve useBorderBox', () => { + const model = createContentModelDocument(); + const table = createTable(1); + const cell1 = createTableCell(false, false, false, { + backgroundColor: 'green', + useBorderBox: true, + }); + + table.format.backgroundColor = 'red'; + + cell1.isSelected = true; + + table.rows[0].cells.push(cell1); + model.blocks.push(table); + + const blocks: any[] = []; + const segments: any[] = []; + const tables: any[] = []; + + const result = clearModelFormat(model, blocks, segments, tables); + + const resultTable = model.blocks[0] as any; + expect(resultTable.rows[0].cells[0].format.useBorderBox).toBe(true); + expect(resultTable.rows[0].cells[0].format.backgroundColor).toBeUndefined(); + + expect(blocks).toEqual([]); + expect(segments).toEqual([]); + expect(tables).toEqual([[table, true]]); + expect(result).toBeFalse(); + }); + + it('Model with selection under table should preserve borders', () => { + const model = createContentModelDocument(); + const table = createTable(1); + const cell1 = createTableCell(false, false, false, { + backgroundColor: 'green', + borderTop: '3px solid blue', + borderBottom: '3px solid blue', + borderLeft: '3px solid blue', + borderRight: '3px solid blue', + }); + + // Set borderOverride so applyTableFormat doesn't overwrite borders + cell1.dataset = { + editingInfo: '{"borderOverride":true}', + }; + + table.format.backgroundColor = 'red'; + + cell1.isSelected = true; + + table.rows[0].cells.push(cell1); + model.blocks.push(table); + + const blocks: any[] = []; + const segments: any[] = []; + const tables: any[] = []; + + const result = clearModelFormat(model, blocks, segments, tables); + + const resultTable = model.blocks[0] as any; + expect(resultTable.rows[0].cells[0].format.borderTop).toBe('3px solid blue'); + expect(resultTable.rows[0].cells[0].format.borderBottom).toBe('3px solid blue'); + expect(resultTable.rows[0].cells[0].format.borderLeft).toBe('3px solid blue'); + expect(resultTable.rows[0].cells[0].format.borderRight).toBe('3px solid blue'); + expect(resultTable.rows[0].cells[0].format.backgroundColor).toBeUndefined(); + + expect(blocks).toEqual([]); + expect(segments).toEqual([]); + expect(tables).toEqual([[table, true]]); + expect(result).toBeFalse(); + }); + + it('Model with selection under table should clear non-preserved properties and keep preserved ones', () => { + const model = createContentModelDocument(); + const table = createTable(1); + const cell1 = createTableCell(false, false, false, { + // Properties that should be preserved + useBorderBox: true, + verticalAlign: 'middle', + height: '50px', + width: '100px', + borderTop: '2px solid black', + borderBottom: '2px solid black', + borderLeft: '2px solid black', + borderRight: '2px solid black', + // Properties that should be cleared + backgroundColor: 'green', + textAlign: 'center', + direction: 'rtl', + htmlAlign: 'center', + marginTop: '10px', + marginBottom: '10px', + marginLeft: '10px', + marginRight: '10px', + paddingTop: '5px', + paddingBottom: '5px', + paddingLeft: '5px', + paddingRight: '5px', + }); + + // Set overrides so applyTableFormat doesn't overwrite preserved values + cell1.dataset = { + editingInfo: '{"vAlignOverride":true,"borderOverride":true}', + }; + + table.format.backgroundColor = 'red'; + + cell1.isSelected = true; + + table.rows[0].cells.push(cell1); + model.blocks.push(table); + + const blocks: any[] = []; + const segments: any[] = []; + const tables: any[] = []; + + const result = clearModelFormat(model, blocks, segments, tables); + + const resultTable = model.blocks[0] as any; + const cellFormat = resultTable.rows[0].cells[0].format; + + // Preserved properties should exist + expect(cellFormat.useBorderBox).toBe(true); + expect(cellFormat.verticalAlign).toBe('middle'); + expect(cellFormat.height).toBe('50px'); + expect(cellFormat.width).toBe('100px'); + expect(cellFormat.borderTop).toBe('2px solid black'); + expect(cellFormat.borderBottom).toBe('2px solid black'); + expect(cellFormat.borderLeft).toBe('2px solid black'); + expect(cellFormat.borderRight).toBe('2px solid black'); + + // Cleared properties should not exist + expect(cellFormat.backgroundColor).toBeUndefined(); + expect(cellFormat.textAlign).toBeUndefined(); + expect(cellFormat.direction).toBeUndefined(); + expect(cellFormat.htmlAlign).toBeUndefined(); + expect(cellFormat.marginTop).toBeUndefined(); + expect(cellFormat.marginBottom).toBeUndefined(); + expect(cellFormat.marginLeft).toBeUndefined(); + expect(cellFormat.marginRight).toBeUndefined(); + expect(cellFormat.paddingTop).toBeUndefined(); + expect(cellFormat.paddingBottom).toBeUndefined(); + expect(cellFormat.paddingLeft).toBeUndefined(); + expect(cellFormat.paddingRight).toBeUndefined(); + + expect(blocks).toEqual([]); + expect(segments).toEqual([]); + expect(tables).toEqual([[table, true]]); + expect(result).toBeFalse(); + }); }); diff --git a/packages/roosterjs-content-model-api/test/modelApi/table/alignTableCellTest.ts b/packages/roosterjs-content-model-api/test/modelApi/table/alignTableCellTest.ts index a943b4320f13..046287e4bbf7 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/table/alignTableCellTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/table/alignTableCellTest.ts @@ -1,4 +1,10 @@ -import { createTable, createTableCell } from 'roosterjs-content-model-dom'; +import { + createListItem, + createListLevel, + createParagraph, + createTable, + createTableCell, +} from 'roosterjs-content-model-dom'; import { ContentModelTableCellFormat, TableCellHorizontalAlignOperation, @@ -184,6 +190,107 @@ describe('alignTableCellHorizontally', () => { true /* isRTL */ ); }); + + it('align to left with ListItem block', () => { + const table = createTable(1); + const cell = createTableCell(1, 1, false); + const listItem = createListItem([createListLevel('OL')]); + cell.blocks.push(listItem); + table.rows[0].cells.push(cell); + cell.isSelected = true; + + alignTableCellHorizontally(table, 'alignCellLeft'); + + expect(cell.format.textAlign).toEqual('start'); + expect(listItem.format.textAlign).toEqual('start'); + }); + + it('align to center with ListItem block', () => { + const table = createTable(1); + const cell = createTableCell(1, 1, false); + const listItem = createListItem([createListLevel('OL')]); + cell.blocks.push(listItem); + table.rows[0].cells.push(cell); + cell.isSelected = true; + + alignTableCellHorizontally(table, 'alignCellCenter'); + + expect(cell.format.textAlign).toEqual('center'); + expect(listItem.format.textAlign).toEqual('center'); + }); + + it('align to right with ListItem block', () => { + const table = createTable(1); + const cell = createTableCell(1, 1, false); + const listItem = createListItem([createListLevel('OL')]); + cell.blocks.push(listItem); + table.rows[0].cells.push(cell); + cell.isSelected = true; + + alignTableCellHorizontally(table, 'alignCellRight'); + + expect(cell.format.textAlign).toEqual('end'); + expect(listItem.format.textAlign).toEqual('end'); + }); + + it('align to left with ListItem block - RTL', () => { + const table = createTable(1); + const cell = createTableCell(1, 1, false, { direction: 'rtl' }); + const listItem = createListItem([createListLevel('OL')]); + cell.blocks.push(listItem); + table.rows[0].cells.push(cell); + cell.isSelected = true; + + alignTableCellHorizontally(table, 'alignCellLeft'); + + expect(cell.format.textAlign).toEqual('end'); + expect(listItem.format.textAlign).toEqual('end'); + }); + + it('align to right with ListItem block - RTL', () => { + const table = createTable(1); + const cell = createTableCell(1, 1, false, { direction: 'rtl' }); + const listItem = createListItem([createListLevel('OL')]); + cell.blocks.push(listItem); + table.rows[0].cells.push(cell); + cell.isSelected = true; + + alignTableCellHorizontally(table, 'alignCellRight'); + + expect(cell.format.textAlign).toEqual('start'); + expect(listItem.format.textAlign).toEqual('start'); + }); + + it('align with mixed Paragraph and ListItem blocks', () => { + const table = createTable(1); + const cell = createTableCell(1, 1, false); + const paragraph = createParagraph(false, { textAlign: 'end' }); + const listItem = createListItem([createListLevel('OL')]); + cell.blocks.push(paragraph); + cell.blocks.push(listItem); + table.rows[0].cells.push(cell); + cell.isSelected = true; + + alignTableCellHorizontally(table, 'alignCellCenter'); + + expect(cell.format.textAlign).toEqual('center'); + expect(paragraph.format.textAlign).toBeUndefined(); + expect(listItem.format.textAlign).toEqual('center'); + }); + + it('paragraph without textAlign should remain unchanged', () => { + const table = createTable(1); + const cell = createTableCell(1, 1, false); + const paragraph = createParagraph(); + cell.blocks.push(paragraph); + table.rows[0].cells.push(cell); + cell.isSelected = true; + + alignTableCellHorizontally(table, 'alignCellCenter'); + + expect(cell.format.textAlign).toEqual('center'); + expect(paragraph.format.textAlign).toBeUndefined(); + }); }); describe('alignTableCellVertically', () => { diff --git a/packages/roosterjs-content-model-api/test/modelApi/table/shiftCellsTest.ts b/packages/roosterjs-content-model-api/test/modelApi/table/shiftCellsTest.ts new file mode 100644 index 000000000000..e7ec1a2b3ad6 --- /dev/null +++ b/packages/roosterjs-content-model-api/test/modelApi/table/shiftCellsTest.ts @@ -0,0 +1,443 @@ +import { + createParagraph, + createTable, + createTableCell, + createText, +} from 'roosterjs-content-model-dom'; +import { shiftCells } from '../../../lib/modelApi/table/shiftCells'; + +describe('shiftCells - shiftCellsLeft', () => { + it('no selection', () => { + const table = createTable(2); + const cell1 = createTableCell(); + const cell2 = createTableCell(); + table.rows[0].cells.push(cell1, cell2); + table.rows[1].cells.push(createTableCell(), createTableCell()); + + shiftCells(table, 'shiftCellsLeft'); + + // No changes when no selection + expect(table.rows[0].cells[0].blocks).toEqual(cell1.blocks); + expect(table.rows[0].cells[1].blocks).toEqual(cell2.blocks); + }); + + it('shift single cell left', () => { + const table = createTable(2); + const cell1 = createTableCell(); + const cell2 = createTableCell(); + const cell3 = createTableCell(); + + const para1 = createParagraph(); + para1.segments.push(createText('Cell1')); + cell1.blocks.push(para1); + + const para2 = createParagraph(); + para2.segments.push(createText('Cell2')); + cell2.blocks.push(para2); + + const para3 = createParagraph(); + para3.segments.push(createText('Cell3')); + cell3.blocks.push(para3); + + cell1.isSelected = true; + + table.rows[0].cells.push(cell1, cell2, cell3); + table.rows[1].cells.push(createTableCell(), createTableCell(), createTableCell()); + + shiftCells(table, 'shiftCellsLeft'); + + // Cell1 should now have Cell2's content, Cell2 should have Cell3's content, Cell3 should be empty + expect(table.rows[0].cells[0].blocks).toEqual([para2]); + expect(table.rows[0].cells[1].blocks).toEqual([para3]); + expect(table.rows[0].cells[2].blocks).toEqual([]); + }); + + it('shift multiple cells left in single row', () => { + const table = createTable(1); + const cell1 = createTableCell(); + const cell2 = createTableCell(); + const cell3 = createTableCell(); + const cell4 = createTableCell(); + + const para1 = createParagraph(); + para1.segments.push(createText('Cell1')); + cell1.blocks.push(para1); + + const para2 = createParagraph(); + para2.segments.push(createText('Cell2')); + cell2.blocks.push(para2); + + const para3 = createParagraph(); + para3.segments.push(createText('Cell3')); + cell3.blocks.push(para3); + + const para4 = createParagraph(); + para4.segments.push(createText('Cell4')); + cell4.blocks.push(para4); + + cell1.isSelected = true; + cell2.isSelected = true; + + table.rows[0].cells.push(cell1, cell2, cell3, cell4); + + shiftCells(table, 'shiftCellsLeft'); + + // Selection spans 2 cells, so shift by 2 + expect(table.rows[0].cells[0].blocks).toEqual([para3]); + expect(table.rows[0].cells[1].blocks).toEqual([para4]); + expect(table.rows[0].cells[2].blocks).toEqual([]); + expect(table.rows[0].cells[3].blocks).toEqual([]); + }); + + it('shift cells left across multiple rows', () => { + const table = createTable(2); + const cell1 = createTableCell(); + const cell2 = createTableCell(); + const cell3 = createTableCell(); + const cell4 = createTableCell(); + + const para1 = createParagraph(); + para1.segments.push(createText('Row1Cell1')); + cell1.blocks.push(para1); + + const para2 = createParagraph(); + para2.segments.push(createText('Row1Cell2')); + cell2.blocks.push(para2); + + const para3 = createParagraph(); + para3.segments.push(createText('Row2Cell1')); + cell3.blocks.push(para3); + + const para4 = createParagraph(); + para4.segments.push(createText('Row2Cell2')); + cell4.blocks.push(para4); + + cell1.isSelected = true; + cell3.isSelected = true; + + table.rows[0].cells.push(cell1, cell2); + table.rows[1].cells.push(cell3, cell4); + + shiftCells(table, 'shiftCellsLeft'); + + // Both rows should shift left + expect(table.rows[0].cells[0].blocks).toEqual([para2]); + expect(table.rows[0].cells[1].blocks).toEqual([]); + expect(table.rows[1].cells[0].blocks).toEqual([para4]); + expect(table.rows[1].cells[1].blocks).toEqual([]); + }); + + it('shift cells left when selection is at the end of row', () => { + const table = createTable(1); + const cell1 = createTableCell(); + const cell2 = createTableCell(); + const cell3 = createTableCell(); + + const para1 = createParagraph(); + para1.segments.push(createText('Cell1')); + cell1.blocks.push(para1); + + const para2 = createParagraph(); + para2.segments.push(createText('Cell2')); + cell2.blocks.push(para2); + + const para3 = createParagraph(); + para3.segments.push(createText('Cell3')); + cell3.blocks.push(para3); + + cell3.isSelected = true; + + table.rows[0].cells.push(cell1, cell2, cell3); + + shiftCells(table, 'shiftCellsLeft'); + + // Only the last cell is selected, no cell to shift from + expect(table.rows[0].cells[0].blocks).toEqual([para1]); + expect(table.rows[0].cells[1].blocks).toEqual([para2]); + expect(table.rows[0].cells[2].blocks).toEqual([]); + }); +}); + +describe('shiftCells - shiftCellsUp', () => { + it('no selection', () => { + const table = createTable(2); + const cell1 = createTableCell(); + const cell2 = createTableCell(); + table.rows[0].cells.push(cell1, cell2); + table.rows[1].cells.push(createTableCell(), createTableCell()); + + shiftCells(table, 'shiftCellsUp'); + + // No changes when no selection + expect(table.rows[0].cells[0].blocks).toEqual(cell1.blocks); + expect(table.rows[0].cells[1].blocks).toEqual(cell2.blocks); + }); + + it('shift single cell up', () => { + const table = createTable(3); + const cell1 = createTableCell(); + const cell2 = createTableCell(); + const cell3 = createTableCell(); + + const para1 = createParagraph(); + para1.segments.push(createText('Row1')); + cell1.blocks.push(para1); + + const para2 = createParagraph(); + para2.segments.push(createText('Row2')); + cell2.blocks.push(para2); + + const para3 = createParagraph(); + para3.segments.push(createText('Row3')); + cell3.blocks.push(para3); + + cell1.isSelected = true; + + table.rows[0].cells.push(cell1); + table.rows[1].cells.push(cell2); + table.rows[2].cells.push(cell3); + + shiftCells(table, 'shiftCellsUp'); + + // Cell1 should now have Cell2's content, Cell2 should have Cell3's content, Cell3 should be empty + expect(table.rows[0].cells[0].blocks).toEqual([para2]); + expect(table.rows[1].cells[0].blocks).toEqual([para3]); + expect(table.rows[2].cells[0].blocks).toEqual([]); + }); + + it('shift multiple cells up in single column', () => { + const table = createTable(4); + const cell1 = createTableCell(); + const cell2 = createTableCell(); + const cell3 = createTableCell(); + const cell4 = createTableCell(); + + const para1 = createParagraph(); + para1.segments.push(createText('Row1')); + cell1.blocks.push(para1); + + const para2 = createParagraph(); + para2.segments.push(createText('Row2')); + cell2.blocks.push(para2); + + const para3 = createParagraph(); + para3.segments.push(createText('Row3')); + cell3.blocks.push(para3); + + const para4 = createParagraph(); + para4.segments.push(createText('Row4')); + cell4.blocks.push(para4); + + cell1.isSelected = true; + cell2.isSelected = true; + + table.rows[0].cells.push(cell1); + table.rows[1].cells.push(cell2); + table.rows[2].cells.push(cell3); + table.rows[3].cells.push(cell4); + + shiftCells(table, 'shiftCellsUp'); + + // Selection spans 2 rows, so shift by 2 + expect(table.rows[0].cells[0].blocks).toEqual([para3]); + expect(table.rows[1].cells[0].blocks).toEqual([para4]); + expect(table.rows[2].cells[0].blocks).toEqual([]); + expect(table.rows[3].cells[0].blocks).toEqual([]); + }); + + it('shift cells up across multiple columns', () => { + const table = createTable(2); + const cell1 = createTableCell(); + const cell2 = createTableCell(); + const cell3 = createTableCell(); + const cell4 = createTableCell(); + + const para1 = createParagraph(); + para1.segments.push(createText('Row1Col1')); + cell1.blocks.push(para1); + + const para2 = createParagraph(); + para2.segments.push(createText('Row1Col2')); + cell2.blocks.push(para2); + + const para3 = createParagraph(); + para3.segments.push(createText('Row2Col1')); + cell3.blocks.push(para3); + + const para4 = createParagraph(); + para4.segments.push(createText('Row2Col2')); + cell4.blocks.push(para4); + + cell1.isSelected = true; + cell2.isSelected = true; + + table.rows[0].cells.push(cell1, cell2); + table.rows[1].cells.push(cell3, cell4); + + shiftCells(table, 'shiftCellsUp'); + + // Both columns should shift up + expect(table.rows[0].cells[0].blocks).toEqual([para3]); + expect(table.rows[0].cells[1].blocks).toEqual([para4]); + expect(table.rows[1].cells[0].blocks).toEqual([]); + expect(table.rows[1].cells[1].blocks).toEqual([]); + }); + + it('shift cells up when selection is at the bottom of column', () => { + const table = createTable(3); + const cell1 = createTableCell(); + const cell2 = createTableCell(); + const cell3 = createTableCell(); + + const para1 = createParagraph(); + para1.segments.push(createText('Row1')); + cell1.blocks.push(para1); + + const para2 = createParagraph(); + para2.segments.push(createText('Row2')); + cell2.blocks.push(para2); + + const para3 = createParagraph(); + para3.segments.push(createText('Row3')); + cell3.blocks.push(para3); + + cell3.isSelected = true; + + table.rows[0].cells.push(cell1); + table.rows[1].cells.push(cell2); + table.rows[2].cells.push(cell3); + + shiftCells(table, 'shiftCellsUp'); + + // Only the last row is selected, no cell to shift from + expect(table.rows[0].cells[0].blocks).toEqual([para1]); + expect(table.rows[1].cells[0].blocks).toEqual([para2]); + expect(table.rows[2].cells[0].blocks).toEqual([]); + }); + + it('shift cells up in middle of table', () => { + const table = createTable(4); + const cell1 = createTableCell(); + const cell2 = createTableCell(); + const cell3 = createTableCell(); + const cell4 = createTableCell(); + + const para1 = createParagraph(); + para1.segments.push(createText('Row1')); + cell1.blocks.push(para1); + + const para2 = createParagraph(); + para2.segments.push(createText('Row2')); + cell2.blocks.push(para2); + + const para3 = createParagraph(); + para3.segments.push(createText('Row3')); + cell3.blocks.push(para3); + + const para4 = createParagraph(); + para4.segments.push(createText('Row4')); + cell4.blocks.push(para4); + + cell2.isSelected = true; + + table.rows[0].cells.push(cell1); + table.rows[1].cells.push(cell2); + table.rows[2].cells.push(cell3); + table.rows[3].cells.push(cell4); + + shiftCells(table, 'shiftCellsUp'); + + // Middle cell selected, shift content from below + expect(table.rows[0].cells[0].blocks).toEqual([para1]); + expect(table.rows[1].cells[0].blocks).toEqual([para3]); + expect(table.rows[2].cells[0].blocks).toEqual([para4]); + expect(table.rows[3].cells[0].blocks).toEqual([]); + }); +}); + +describe('shiftCells - 3x3 table scenarios', () => { + function createTableWithContent(): { + table: ReturnType; + paragraphs: ReturnType[]; + } { + const table = createTable(3); + const paragraphs: ReturnType[] = []; + + for (let i = 0; i < 3; i++) { + for (let j = 0; j < 3; j++) { + const cell = createTableCell(); + const para = createParagraph(); + para.segments.push(createText(`R${i}C${j}`)); + cell.blocks.push(para); + table.rows[i].cells.push(cell); + paragraphs.push(para); + } + } + + return { table, paragraphs }; + } + + it('shift center cell left', () => { + const { table, paragraphs } = createTableWithContent(); + table.rows[1].cells[1].isSelected = true; + + shiftCells(table, 'shiftCellsLeft'); + + // Row 1: R1C0 unchanged, R1C1 gets R1C2, R1C2 becomes empty + expect(table.rows[1].cells[0].blocks).toEqual([paragraphs[3]]); // R1C0 + expect(table.rows[1].cells[1].blocks).toEqual([paragraphs[5]]); // R1C2 + expect(table.rows[1].cells[2].blocks).toEqual([]); + }); + + it('shift center cell up', () => { + const { table, paragraphs } = createTableWithContent(); + table.rows[1].cells[1].isSelected = true; + + shiftCells(table, 'shiftCellsUp'); + + // Column 1: R0C1 unchanged, R1C1 gets R2C1, R2C1 becomes empty + expect(table.rows[0].cells[1].blocks).toEqual([paragraphs[1]]); // R0C1 + expect(table.rows[1].cells[1].blocks).toEqual([paragraphs[7]]); // R2C1 + expect(table.rows[2].cells[1].blocks).toEqual([]); + }); + + it('shift 2x2 selection left', () => { + const { table, paragraphs } = createTableWithContent(); + table.rows[0].cells[0].isSelected = true; + table.rows[0].cells[1].isSelected = true; + table.rows[1].cells[0].isSelected = true; + table.rows[1].cells[1].isSelected = true; + + shiftCells(table, 'shiftCellsLeft'); + + // Selection is 2 columns wide, shift by 2 + // Row 0: R0C0 gets R0C2, R0C1 empty, R0C2 empty + // Row 1: R1C0 gets R1C2, R1C1 empty, R1C2 empty + expect(table.rows[0].cells[0].blocks).toEqual([paragraphs[2]]); // R0C2 + expect(table.rows[0].cells[1].blocks).toEqual([]); + expect(table.rows[0].cells[2].blocks).toEqual([]); + expect(table.rows[1].cells[0].blocks).toEqual([paragraphs[5]]); // R1C2 + expect(table.rows[1].cells[1].blocks).toEqual([]); + expect(table.rows[1].cells[2].blocks).toEqual([]); + }); + + it('shift 2x2 selection up', () => { + const { table, paragraphs } = createTableWithContent(); + table.rows[0].cells[0].isSelected = true; + table.rows[0].cells[1].isSelected = true; + table.rows[1].cells[0].isSelected = true; + table.rows[1].cells[1].isSelected = true; + + shiftCells(table, 'shiftCellsUp'); + + // Selection is 2 rows tall, shift by 2 + // Col 0: R0C0 gets R2C0, R1C0 empty, R2C0 empty + // Col 1: R0C1 gets R2C1, R1C1 empty, R2C1 empty + expect(table.rows[0].cells[0].blocks).toEqual([paragraphs[6]]); // R2C0 + expect(table.rows[0].cells[1].blocks).toEqual([paragraphs[7]]); // R2C1 + expect(table.rows[1].cells[0].blocks).toEqual([]); + expect(table.rows[1].cells[1].blocks).toEqual([]); + expect(table.rows[2].cells[0].blocks).toEqual([]); + expect(table.rows[2].cells[1].blocks).toEqual([]); + }); +}); diff --git a/packages/roosterjs-content-model-api/test/modelApi/table/tableContentTest.ts b/packages/roosterjs-content-model-api/test/modelApi/table/tableContentTest.ts new file mode 100644 index 000000000000..5d5cace2d8a3 --- /dev/null +++ b/packages/roosterjs-content-model-api/test/modelApi/table/tableContentTest.ts @@ -0,0 +1,323 @@ +import { + getSelectedContentForTable, + insertTableContent, +} from '../../../lib/modelApi/table/tableContent'; +import { + ContentModelBlock, + ContentModelDocument, + ContentModelSettings, + DOMSelection, + DomToModelOption, + DomToModelSettings, + EditorEnvironment, + IEditor, + ModelToDomOption, + ModelToDomSettings, +} from 'roosterjs-content-model-types'; +import { + createContentModelDocument, + createParagraph, + createTable, + createTableCell, + createText, +} from 'roosterjs-content-model-dom'; + +describe('getSelectedContentForTable', () => { + let mockDocument: Document; + + function createMockEditor( + selection: DOMSelection | null, + model: ContentModelDocument + ): IEditor { + mockDocument = document.implementation.createHTMLDocument('test'); + + return { + getDocument: (): Document => mockDocument, + getDOMSelection: (): DOMSelection | null => selection, + getContentModelCopy: (): ContentModelDocument => model, + getEnvironment: (): EditorEnvironment => { + return { + document: mockDocument, + isSafari: false, + domToModelSettings: {} as ContentModelSettings< + DomToModelOption, + DomToModelSettings + >, + modelToDomSettings: {} as ContentModelSettings< + ModelToDomOption, + ModelToDomSettings + >, + }; + }, + } as any; + } + + it('should return empty array when no selection', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + para.segments.push(createText('text')); + model.blocks.push(para); + + const editor = createMockEditor(null, model); + + const result = getSelectedContentForTable(editor); + + expect(result).toEqual([]); + }); + + it('should return empty array when selection is collapsed', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + para.segments.push(createText('text')); + model.blocks.push(para); + + const range = document.createRange(); + + const selection: DOMSelection = { + type: 'range', + range, + isReverted: false, + }; + + const editor = createMockEditor(selection, model); + + const result = getSelectedContentForTable(editor); + + expect(result).toEqual([]); + }); + + it('should return single paragraph with selected text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const text = createText('selected text'); + text.isSelected = true; + para.segments.push(text); + model.blocks.push(para); + + mockDocument = document.implementation.createHTMLDocument('test'); + const div = mockDocument.createElement('div'); + div.textContent = 'selected text'; + mockDocument.body.appendChild(div); + + const range = mockDocument.createRange(); + range.selectNodeContents(div); + + const selection: DOMSelection = { + type: 'range', + range, + isReverted: false, + }; + + const editor = createMockEditor(selection, model); + + const result = getSelectedContentForTable(editor); + + expect(result.length).toBe(1); + expect(result[0].length).toBe(1); + expect(result[0][0].blockType).toBe('Paragraph'); + }); + + it('should extract content from table cells preserving rows', () => { + const model = createContentModelDocument(); + + // Create a 2x2 table with content + const table = createTable(2); + + // Row 0 + const cell00 = createTableCell(); + const para00 = createParagraph(); + const text00 = createText('Cell 0,0'); + text00.isSelected = true; + para00.segments.push(text00); + cell00.blocks.push(para00); + cell00.isSelected = true; + + const cell01 = createTableCell(); + const para01 = createParagraph(); + const text01 = createText('Cell 0,1'); + text01.isSelected = true; + para01.segments.push(text01); + cell01.blocks.push(para01); + cell01.isSelected = true; + + table.rows[0].cells.push(cell00, cell01); + + // Row 1 + const cell10 = createTableCell(); + const para10 = createParagraph(); + const text10 = createText('Cell 1,0'); + text10.isSelected = true; + para10.segments.push(text10); + cell10.blocks.push(para10); + cell10.isSelected = true; + + const cell11 = createTableCell(); + const para11 = createParagraph(); + const text11 = createText('Cell 1,1'); + text11.isSelected = true; + para11.segments.push(text11); + cell11.blocks.push(para11); + cell11.isSelected = true; + + table.rows[1].cells.push(cell10, cell11); + + model.blocks.push(table); + + mockDocument = document.implementation.createHTMLDocument('test'); + const tableElement = mockDocument.createElement('table'); + mockDocument.body.appendChild(tableElement); + + const selection: DOMSelection = { + type: 'table', + table: tableElement, + firstColumn: 0, + lastColumn: 1, + firstRow: 0, + lastRow: 1, + }; + + const editor = createMockEditor(selection, model); + + const result = getSelectedContentForTable(editor); + + // Should have 2 rows with 2 blocks each + expect(result.length).toBe(2); + expect(result[0].length).toBe(2); + expect(result[1].length).toBe(2); + }); +}); + +describe('insertTableContent', () => { + it('should insert content into existing rows', () => { + const table = createTable(2); + table.rows[0].cells.push(createTableCell(), createTableCell()); + table.rows[1].cells.push(createTableCell(), createTableCell()); + + const para1 = createParagraph(); + para1.segments.push(createText('Row 1')); + + const para2 = createParagraph(); + para2.segments.push(createText('Row 2')); + + const contentRows: ContentModelBlock[][] = [[para1], [para2]]; + + insertTableContent(table, contentRows, 2); + + expect(table.rows[0].cells[0].blocks[0]).toBe(para1); + expect(table.rows[1].cells[0].blocks[0]).toBe(para2); + }); + + it('should create new rows when content exceeds existing rows', () => { + const table = createTable(1); + table.rows[0].cells.push(createTableCell(), createTableCell()); + + const para1 = createParagraph(); + para1.segments.push(createText('Row 1')); + + const para2 = createParagraph(); + para2.segments.push(createText('Row 2')); + + const para3 = createParagraph(); + para3.segments.push(createText('Row 3')); + + const contentRows: ContentModelBlock[][] = [[para1], [para2], [para3]]; + + insertTableContent(table, contentRows, 2); + + expect(table.rows.length).toBe(3); + expect(table.rows[0].cells[0].blocks[0]).toBe(para1); + expect(table.rows[1].cells[0].blocks[0]).toBe(para2); + expect(table.rows[2].cells[0].blocks[0]).toBe(para3); + + // New rows should have correct number of cells + expect(table.rows[1].cells.length).toBe(2); + expect(table.rows[2].cells.length).toBe(2); + }); + + it('should insert multiple blocks per row into corresponding cells', () => { + const table = createTable(2); + table.rows[0].cells.push(createTableCell(), createTableCell()); + table.rows[1].cells.push(createTableCell(), createTableCell()); + + const para00 = createParagraph(); + para00.segments.push(createText('Cell 0,0')); + + const para01 = createParagraph(); + para01.segments.push(createText('Cell 0,1')); + + const para10 = createParagraph(); + para10.segments.push(createText('Cell 1,0')); + + const para11 = createParagraph(); + para11.segments.push(createText('Cell 1,1')); + + const contentRows: ContentModelBlock[][] = [ + [para00, para01], + [para10, para11], + ]; + + insertTableContent(table, contentRows, 2); + + expect(table.rows[0].cells[0].blocks[0]).toBe(para00); + expect(table.rows[0].cells[1].blocks[0]).toBe(para01); + expect(table.rows[1].cells[0].blocks[0]).toBe(para10); + expect(table.rows[1].cells[1].blocks[0]).toBe(para11); + }); + + it('should apply custom cell format to new rows', () => { + const table = createTable(1); + table.rows[0].cells.push(createTableCell()); + + const para1 = createParagraph(); + para1.segments.push(createText('Row 1')); + + const para2 = createParagraph(); + para2.segments.push(createText('Row 2')); + + const contentRows: ContentModelBlock[][] = [[para1], [para2]]; + const customFormat = { minWidth: '50px' }; + + insertTableContent(table, contentRows, 2, customFormat); + + expect(table.rows.length).toBe(2); + expect(table.rows[1].cells[0].format).toEqual(customFormat); + expect(table.rows[1].cells[1].format).toEqual(customFormat); + }); + + it('should not insert content beyond available cells', () => { + const table = createTable(1); + table.rows[0].cells.push(createTableCell()); // Only 1 cell + + const para1 = createParagraph(); + para1.segments.push(createText('Cell 1')); + + const para2 = createParagraph(); + para2.segments.push(createText('Cell 2')); + + const para3 = createParagraph(); + para3.segments.push(createText('Cell 3')); + + // 3 blocks but only 1 cell + const contentRows: ContentModelBlock[][] = [[para1, para2, para3]]; + + insertTableContent(table, contentRows, 1); + + // Only first block should be inserted + expect(table.rows[0].cells[0].blocks[0]).toBe(para1); + expect(table.rows[0].cells.length).toBe(1); + }); + + it('should handle empty content rows', () => { + const table = createTable(2); + table.rows[0].cells.push(createTableCell(), createTableCell()); + table.rows[1].cells.push(createTableCell(), createTableCell()); + + const contentRows: ContentModelBlock[][] = []; + + insertTableContent(table, contentRows, 2); + + // Table should remain unchanged + expect(table.rows.length).toBe(2); + expect(table.rows[0].cells[0].blocks.length).toBe(0); + }); +}); diff --git a/packages/roosterjs-content-model-api/test/publicApi/table/editTableTest.ts b/packages/roosterjs-content-model-api/test/publicApi/table/editTableTest.ts index 7e5fe3090dcd..526e57d0a1ac 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/table/editTableTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/table/editTableTest.ts @@ -9,6 +9,7 @@ import * as insertTableRow from '../../../lib/modelApi/table/insertTableRow'; import * as mergeTableCells from '../../../lib/modelApi/table/mergeTableCells'; import * as mergeTableColumn from '../../../lib/modelApi/table/mergeTableColumn'; import * as mergeTableRow from '../../../lib/modelApi/table/mergeTableRow'; +import * as shiftCells from '../../../lib/modelApi/table/shiftCells'; import * as splitTableCellHorizontally from '../../../lib/modelApi/table/splitTableCellHorizontally'; import * as splitTableCellVertically from '../../../lib/modelApi/table/splitTableCellVertically'; import { editTable } from '../../../lib/publicApi/table/editTable'; @@ -252,6 +253,22 @@ describe('editTable', () => { }); }); + describe('shiftCells', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(shiftCells, 'shiftCells'); + }); + + it('shiftCellsLeft', () => { + runTest('shiftCellsLeft', spy, 'shiftCellsLeft'); + }); + + it('shiftCellsUp', () => { + runTest('shiftCellsUp', spy, 'shiftCellsUp'); + }); + }); + it('edit in safar', () => { const spy = spyOn(alignTableCell, 'alignTableCellHorizontally'); const collapseSpy = jasmine.createSpy('collapse'); diff --git a/packages/roosterjs-content-model-api/test/publicApi/table/insertTableTest.ts b/packages/roosterjs-content-model-api/test/publicApi/table/insertTableTest.ts index 8708ddaf9a13..30664ad48704 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/table/insertTableTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/table/insertTableTest.ts @@ -6,19 +6,42 @@ describe('insertTable', () => { let focusSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; let getPendingFormatSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; + let getContentModelCopySpy: jasmine.Spy; + let mockDocument: Document; beforeEach(() => { + mockDocument = document.implementation.createHTMLDocument('test'); focusSpy = jasmine.createSpy('focus'); formatContentModelSpy = jasmine.createSpy('formatContentModel'); getPendingFormatSpy = jasmine.createSpy('getPendingFormat'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection').and.returnValue(null); + getContentModelCopySpy = jasmine.createSpy('getContentModelCopy'); editor = { focus: focusSpy, formatContentModel: formatContentModelSpy, getPendingFormat: getPendingFormatSpy, + getDOMSelection: getDOMSelectionSpy, + getContentModelCopy: getContentModelCopySpy, } as any; }); + function setupRangeSelection(model: ContentModelDocument) { + const div = mockDocument.createElement('div'); + div.textContent = 'selected'; + mockDocument.body.appendChild(div); + const range = mockDocument.createRange(); + range.selectNodeContents(div); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range, + isReverted: false, + }); + getContentModelCopySpy.and.returnValue(model); + } + describe('insertTable with indentation', () => { it('should insert table with proper indentation when cursor is in indented text', () => { // Arrange @@ -440,4 +463,785 @@ describe('insertTable', () => { }); }); }); + + describe('insertTable with range selection (insertTableContent)', () => { + it('should insert table with selected paragraphs content into first column cells', () => { + // Arrange - Multiple paragraphs with selected text (range selection) + const selectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Line 1', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Line 2', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Line 3', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + }; + + // Setup the range selection mock + setupRangeSelection(selectedModel); + + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Line 1', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Line 2', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Line 3', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + }; + + let resultModel: ContentModelDocument | null = null; + + formatContentModelSpy.and.callFake((callback: any) => { + const result = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + resultModel = model; + return result; + }); + + // Act - insert 2 columns x 3 rows table + insertTable(editor, 2, 3); + + // Assert + expect(focusSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + + // Verify table was created with content in first column + expect(resultModel!.blocks.length).toBe(2); // Table + trailing paragraph + expect(resultModel!.blocks[0].blockType).toBe('Table'); + + const table = resultModel!.blocks[0] as any; + expect(table.rows.length).toBe(3); + + // First row, first cell should have "Line 1" content + expect(table.rows[0].cells[0].blocks[0].segments).toContain( + jasmine.objectContaining({ + segmentType: 'Text', + text: 'Line 1', + }) + ); + + // Second row, first cell should have "Line 2" content + expect(table.rows[1].cells[0].blocks[0].segments).toContain( + jasmine.objectContaining({ + segmentType: 'Text', + text: 'Line 2', + }) + ); + + // Third row, first cell should have "Line 3" content + expect(table.rows[2].cells[0].blocks[0].segments).toContain( + jasmine.objectContaining({ + segmentType: 'Text', + text: 'Line 3', + }) + ); + + // Second column cells should have empty content (Br) + expect(table.rows[0].cells[1].blocks[0].segments[0].segmentType).toBe('Br'); + expect(table.rows[1].cells[1].blocks[0].segments[0].segmentType).toBe('Br'); + expect(table.rows[2].cells[1].blocks[0].segments[0].segmentType).toBe('Br'); + }); + + it('should add extra rows when more selected paragraphs than requested rows', () => { + // Arrange - 4 paragraphs selected but only 2 rows requested + const selectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Line 1', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Line 2', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Line 3', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Line 4', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + }; + + // Setup the range selection mock + setupRangeSelection(selectedModel); + + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Line 1', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Line 2', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Line 3', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Line 4', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + }; + + let resultModel: ContentModelDocument | null = null; + + formatContentModelSpy.and.callFake((callback: any) => { + const result = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + resultModel = model; + return result; + }); + + // Act - insert 2x2 table but with 4 selected paragraphs + insertTable(editor, 2, 2); + + // Assert - Should have 4 rows to accommodate all content + expect(resultModel!.blocks[0].blockType).toBe('Table'); + const table = resultModel!.blocks[0] as any; + expect(table.rows.length).toBe(4); + + // Verify all 4 lines are in the table + expect(table.rows[0].cells[0].blocks[0].segments).toContain( + jasmine.objectContaining({ segmentType: 'Text', text: 'Line 1' }) + ); + expect(table.rows[1].cells[0].blocks[0].segments).toContain( + jasmine.objectContaining({ segmentType: 'Text', text: 'Line 2' }) + ); + expect(table.rows[2].cells[0].blocks[0].segments).toContain( + jasmine.objectContaining({ segmentType: 'Text', text: 'Line 3' }) + ); + expect(table.rows[3].cells[0].blocks[0].segments).toContain( + jasmine.objectContaining({ segmentType: 'Text', text: 'Line 4' }) + ); + }); + + it('should insert table with selected list items into cells', () => { + // Arrange - List items with selection + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Item 1', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'UL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Item 2', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'UL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + ], + }; + + let resultModel: ContentModelDocument | null = null; + + formatContentModelSpy.and.callFake((callback: any) => { + const result = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + resultModel = model; + return result; + }); + + // Act + insertTable(editor, 2, 2); + + // Assert - Table is inserted inside the first ListItem + expect(resultModel!.blocks[0].blockType).toBe('BlockGroup'); + const firstListItem = resultModel!.blocks[0] as any; + expect(firstListItem.blockGroupType).toBe('ListItem'); + + // The table is inside the list item + expect(firstListItem.blocks[0].blockType).toBe('Table'); + const table = firstListItem.blocks[0]; + + // Table should have the expected structure + expect(table.rows.length).toBe(2); + expect(table.rows[0].cells.length).toBe(2); + }); + + it('should insert table with selected quote blocks into cells', () => { + // Arrange - FormatContainer (blockquote) with selection + // When selecting content inside FormatContainers, the table is inserted + // inside the first FormatContainer + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Quote 1', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Quote 2', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: {}, + }, + ], + }; + + let resultModel: ContentModelDocument | null = null; + + formatContentModelSpy.and.callFake((callback: any) => { + const result = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + resultModel = model; + return result; + }); + + // Act + insertTable(editor, 2, 2); + + // Assert - Table is inserted inside the first blockquote + expect(resultModel!.blocks[0].blockType).toBe('BlockGroup'); + const firstBlockquote = resultModel!.blocks[0] as any; + expect(firstBlockquote.blockGroupType).toBe('FormatContainer'); + expect(firstBlockquote.tagName).toBe('blockquote'); + + // The table is inside the blockquote + expect(firstBlockquote.blocks[0].blockType).toBe('Table'); + const table = firstBlockquote.blocks[0]; + + // Table should have the expected structure + expect(table.rows.length).toBe(2); + expect(table.rows[0].cells.length).toBe(2); + }); + + it('should insert table with mixed selected content (paragraph and list)', () => { + // Arrange - Mix of paragraph and list item + const selectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Paragraph', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'List Item', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'UL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + ], + }; + + // Setup the range selection mock + setupRangeSelection(selectedModel); + + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Paragraph', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'List Item', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'UL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + ], + }; + + let resultModel: ContentModelDocument | null = null; + + formatContentModelSpy.and.callFake((callback: any) => { + const result = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + resultModel = model; + return result; + }); + + // Act + insertTable(editor, 2, 2); + + // Assert + expect(resultModel!.blocks[0].blockType).toBe('Table'); + const table = resultModel!.blocks[0] as any; + + // First row should have paragraph + expect(table.rows[0].cells[0].blocks[0].blockType).toBe('Paragraph'); + expect(table.rows[0].cells[0].blocks[0].segments).toContain( + jasmine.objectContaining({ segmentType: 'Text', text: 'Paragraph' }) + ); + + // Second row should have the whole list item + expect(table.rows[1].cells[0].blocks[0].blockGroupType).toBe('ListItem'); + }); + + it('should insert table with fewer selected items than rows', () => { + // Arrange - Only 1 paragraph selected, but 3 rows requested + const selectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Only one line', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + }; + + // Setup the range selection mock + setupRangeSelection(selectedModel); + + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Only one line', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + }; + + let resultModel: ContentModelDocument | null = null; + + formatContentModelSpy.and.callFake((callback: any) => { + const result = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + resultModel = model; + return result; + }); + + // Act - insert 2x3 table with only 1 selected paragraph + insertTable(editor, 2, 3); + + // Assert + expect(resultModel!.blocks[0].blockType).toBe('Table'); + const table = resultModel!.blocks[0] as any; + + // Should still have 3 rows as requested + expect(table.rows.length).toBe(3); + + // First row should have the content + expect(table.rows[0].cells[0].blocks[0].segments).toContain( + jasmine.objectContaining({ segmentType: 'Text', text: 'Only one line' }) + ); + + // Other rows should have empty content + expect(table.rows[1].cells[0].blocks[0].segments[0].segmentType).toBe('Br'); + expect(table.rows[2].cells[0].blocks[0].segments[0].segmentType).toBe('Br'); + }); + + it('should preserve custom cell format when inserting content', () => { + // Arrange + const selectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Content 1', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Content 2', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + }; + + // Setup the range selection mock + setupRangeSelection(selectedModel); + + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Content 1', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Content 2', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + }; + + let resultModel: ContentModelDocument | null = null; + + formatContentModelSpy.and.callFake((callback: any) => { + const result = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + resultModel = model; + return result; + }); + + // Act - insert table with custom cell format + insertTable(editor, 2, 2, { verticalAlign: 'middle' }, undefined, { + minWidth: '15px', + }); + + // Assert + expect(resultModel!.blocks[0].blockType).toBe('Table'); + const table = resultModel!.blocks[0] as any; + + // Verify custom format is applied to all cells + expect(table.rows[0].cells[0].format.minWidth).toBe('15px'); + expect(table.rows[0].cells[0].format.verticalAlign).toBe('middle'); + expect(table.rows[0].cells[1].format.minWidth).toBe('15px'); + expect(table.rows[1].cells[0].format.minWidth).toBe('15px'); + expect(table.rows[1].cells[1].format.minWidth).toBe('15px'); + + // Verify content is preserved + expect(table.rows[0].cells[0].blocks[0].segments).toContain( + jasmine.objectContaining({ segmentType: 'Text', text: 'Content 1' }) + ); + expect(table.rows[1].cells[0].blocks[0].segments).toContain( + jasmine.objectContaining({ segmentType: 'Text', text: 'Content 2' }) + ); + }); + }); }); diff --git a/packages/roosterjs-content-model-api/test/publicApi/utils/checkXssTest.ts b/packages/roosterjs-content-model-api/test/publicApi/utils/checkXssTest.ts new file mode 100644 index 000000000000..9069906435da --- /dev/null +++ b/packages/roosterjs-content-model-api/test/publicApi/utils/checkXssTest.ts @@ -0,0 +1,33 @@ +import { checkXss } from '../../../lib/publicApi/utils/checkXss'; + +describe('checkXss', () => { + it('No XSS', () => { + const link = 'https://example.com'; + expect(checkXss(link)).toBe(link); + }); + + it('With XSS', () => { + const link = 's\nc\nr\ni\np\nt:https://example.com'; + expect(checkXss(link)).toBe(''); + }); + + it('With mixed case XSS', () => { + const link = 'S\nC\nr\ni\nP\nt:https://example.com'; + expect(checkXss(link)).toBe(''); + }); + + it('With no XSS but similar pattern', () => { + const link = 'scripting:https://example.com'; + expect(checkXss(link)).toBe(link); + }); + + it('With potential XSS', () => { + const link = 'script:https://example.com'; + expect(checkXss(link)).toBe(''); + }); + + it('With script but it is safe', () => { + const link = 'https://example.com/script:.js'; + expect(checkXss(link)).toBe(link); + }); +}); diff --git a/packages/roosterjs-content-model-core/lib/command/cutCopy/getContentForCopy.ts b/packages/roosterjs-content-model-core/lib/command/cutCopy/getContentForCopy.ts index d17be089fb82..f881dffbf9ea 100644 --- a/packages/roosterjs-content-model-core/lib/command/cutCopy/getContentForCopy.ts +++ b/packages/roosterjs-content-model-core/lib/command/cutCopy/getContentForCopy.ts @@ -1,23 +1,21 @@ import { adjustImageSelectionOnSafari } from './adjustImageSelectionOnSafari'; import { adjustSelectionForCopyCut } from './adjustSelectionForCopyCut'; import { onCreateCopyEntityNode } from '../../override/pasteCopyBlockEntityParser'; -import { preprocessTable } from './preprocessTable'; -import { pruneUnselectedModel } from './pruneUnselectedModel'; -import type { - DOMSelection, - IEditor, - OnNodeCreated, - TextAndHtmlContentForCopy, -} from 'roosterjs-content-model-types'; import { contentModelToDom, contentModelToText, createModelToDomContext, + trimModelForSelection, isElementOfType, isNodeOfType, - iterateSelections, wrap, } from 'roosterjs-content-model-dom'; +import type { + DOMSelection, + IEditor, + OnNodeCreated, + TextAndHtmlContentForCopy, +} from 'roosterjs-content-model-types'; /** * @internal @@ -47,23 +45,15 @@ export function getContentForCopy( ): TextAndHtmlContentForCopy | null { const selection = editor.getDOMSelection(); adjustImageSelectionOnSafari(editor, selection); - if (selection && (selection.type != 'range' || !selection.range.collapsed)) { - const pasteModel = editor.getContentModelCopy('disconnected'); - pruneUnselectedModel(pasteModel); - if (selection.type === 'table') { - iterateSelections(pasteModel, (_, tableContext) => { - if (tableContext?.table) { - preprocessTable(tableContext.table); + if (selection && (selection.type !== 'range' || !selection.range.collapsed)) { + const pasteModel = editor.getContentModelCopy('disconnected'); + const context = createModelToDomContext(); + trimModelForSelection(pasteModel, selection); - return true; - } - return false; - }); - } else if (selection.type === 'range') { + if (selection.type === 'range') { adjustSelectionForCopyCut(pasteModel); } - const context = createModelToDomContext(); context.onNodeCreated = onNodeCreated; const doc = editor.getDocument(); diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts index e8b7b9a6b519..3676ed67ef24 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts @@ -3,24 +3,14 @@ import { areSameSelections } from '../../corePlugin/cache/areSameSelections'; import { ensureUniqueId } from '../setEditorStyle/ensureUniqueId'; import { findLastedCoInMergedCell } from './findLastedCoInMergedCell'; import { findTableCellElement } from './findTableCellElement'; +import { getSafeIdSelector, parseTableCells } from 'roosterjs-content-model-dom'; +import { setTableCellsStyle } from './setTableCellsStyle'; import { toggleCaret } from './toggleCaret'; -import { - getSafeIdSelector, - isNodeOfType, - parseTableCells, - toArray, -} from 'roosterjs-content-model-dom'; -import type { - ParsedTable, - SelectionChangedEvent, - SetDOMSelection, - TableCellCoordinate, -} from 'roosterjs-content-model-types'; +import type { SelectionChangedEvent, SetDOMSelection } from 'roosterjs-content-model-types'; const DOM_SELECTION_CSS_KEY = '_DOMSelection'; const HIDE_SELECTION_CSS_KEY = '_DOMSelectionHideSelection'; const IMAGE_ID = 'image'; -const TABLE_ID = 'table'; const TRANSPARENT_SELECTION_CSS_RULE = 'background-color: transparent !important;'; const SELECTION_SELECTOR = '*::selection'; const DEFAULT_SELECTION_BORDER_COLOR = '#DB626C'; @@ -110,34 +100,9 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC tableSelectionInfo: selection.tableSelectionInfo, }; - const tableId = ensureUniqueId(table, TABLE_ID); - const tableSelector = getSafeIdSelector(tableId); - - const tableSelectors = - firstCell.row == 0 && - firstCell.col == 0 && - lastCell.row == parsedTable.length - 1 && - lastCell.col == (parsedTable[lastCell.row]?.length ?? 0) - 1 - ? [tableSelector, `${tableSelector} *`] - : handleTableSelected( - parsedTable, - tableSelector, - table, - firstCell, - lastCell - ); - core.selection.selection = selection; - const tableSelectionColor = isDarkMode - ? core.selection.tableCellSelectionBackgroundColorDark - : core.selection.tableCellSelectionBackgroundColor; - core.api.setEditorStyle( - core, - DOM_SELECTION_CSS_KEY, - `background-color:${tableSelectionColor}!important;`, - tableSelectors - ); + setTableCellsStyle(core, table, parsedTable, firstCell, lastCell); core.api.setEditorStyle( core, HIDE_SELECTION_CSS_KEY, @@ -182,70 +147,6 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC } }; -function handleTableSelected( - parsedTable: ParsedTable, - tableSelector: string, - table: HTMLTableElement, - firstCell: TableCellCoordinate, - lastCell: TableCellCoordinate -) { - const selectors: string[] = []; - - // Get whether table has thead, tbody or tfoot, then Set the start and end of each of the table children, - // so we can build the selector according the element between the table and the row. - let cont = 0; - const indexes = toArray(table.childNodes) - .filter( - (node): node is HTMLTableSectionElement => - ['THEAD', 'TBODY', 'TFOOT'].indexOf( - isNodeOfType(node, 'ELEMENT_NODE') ? node.tagName : '' - ) > -1 - ) - .map(node => { - const result = { - el: node.tagName, - start: cont, - end: node.childNodes.length + cont, - }; - - cont = result.end; - return result; - }); - - parsedTable.forEach((row, rowIndex) => { - let tdCount = 0; - - //Get current TBODY/THEAD/TFOOT - const midElement = indexes.filter(ind => ind.start <= rowIndex && ind.end > rowIndex)[0]; - const middleElSelector = midElement ? '>' + midElement.el + '>' : '>'; - const currentRow = - midElement && rowIndex + 1 >= midElement.start - ? rowIndex + 1 - midElement.start - : rowIndex + 1; - - for (let cellIndex = 0; cellIndex < row.length; cellIndex++) { - const cell = row[cellIndex]; - - if (typeof cell == 'object') { - tdCount++; - - if ( - rowIndex >= firstCell.row && - rowIndex <= lastCell.row && - cellIndex >= firstCell.col && - cellIndex <= lastCell.col - ) { - const selector = `${tableSelector}${middleElSelector} tr:nth-child(${currentRow})>${cell.tagName}:nth-child(${tdCount})`; - - selectors.push(selector, selector + ' *'); - } - } - } - }); - - return selectors; -} - function setRangeSelection(doc: Document, element: HTMLElement | undefined, collapse: boolean) { if (element && doc.contains(element)) { const range = doc.createRange(); diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setTableCellsStyle.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setTableCellsStyle.ts new file mode 100644 index 000000000000..d24cf146b646 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setTableCellsStyle.ts @@ -0,0 +1,118 @@ +import { ensureUniqueId } from '../setEditorStyle/ensureUniqueId'; +import { getSafeIdSelector, isNodeOfType, toArray } from 'roosterjs-content-model-dom'; +import type { EditorCore, ParsedTable, TableCellCoordinate } from 'roosterjs-content-model-types'; + +const DOM_SELECTION_CSS_KEY = '_DOMSelection'; +const TABLE_ID = 'table'; + +/** + * @internal + * Remove table cell selection styles + * @param core The EditorCore object + */ +export function removeTableCellsStyle(core: EditorCore) { + core.api.setEditorStyle(core, DOM_SELECTION_CSS_KEY, ''); +} + +/** + * @internal + * Set style for table cells in the selection + * @param core The EditorCore object + * @param table The HTML table element + * @param parsedTable The parsed table structure + * @param firstCell The coordinates of the first selected cell + * @param lastCell The coordinates of the last selected cell + */ +export function setTableCellsStyle( + core: EditorCore, + table: HTMLTableElement, + parsedTable: ParsedTable, + firstCell: TableCellCoordinate, + lastCell: TableCellCoordinate +) { + const tableId = ensureUniqueId(table, TABLE_ID); + const tableSelector = getSafeIdSelector(tableId); + const tableSelectionColor = core.lifecycle.isDarkMode + ? core.selection.tableCellSelectionBackgroundColorDark + : core.selection.tableCellSelectionBackgroundColor; + + const tableSelectors = + firstCell.row == 0 && + firstCell.col == 0 && + lastCell.row == parsedTable.length - 1 && + lastCell.col == (parsedTable[lastCell.row]?.length ?? 0) - 1 + ? [tableSelector, `${tableSelector} *`] + : buildTableSelectors(parsedTable, tableSelector, table, firstCell, lastCell); + + core.api.setEditorStyle( + core, + DOM_SELECTION_CSS_KEY, + `background-color:${tableSelectionColor}!important;`, + tableSelectors + ); +} + +/** + * @internal + * Build CSS selectors for table cells within the selection range + */ +function buildTableSelectors( + parsedTable: ParsedTable, + tableSelector: string, + table: HTMLTableElement, + firstCell: TableCellCoordinate, + lastCell: TableCellCoordinate +): string[] { + const selectors: string[] = []; + + let cont = 0; + const indexes = toArray(table.childNodes) + .filter( + (node): node is HTMLTableSectionElement => + ['THEAD', 'TBODY', 'TFOOT'].indexOf( + isNodeOfType(node, 'ELEMENT_NODE') ? node.tagName : '' + ) > -1 + ) + .map(node => { + const result = { + el: node.tagName, + start: cont, + end: node.childNodes.length + cont, + }; + + cont = result.end; + return result; + }); + + parsedTable.forEach((row, rowIndex) => { + let tdCount = 0; + + const midElement = indexes.filter(ind => ind.start <= rowIndex && ind.end > rowIndex)[0]; + const middleElSelector = midElement ? '>' + midElement.el + '>' : '>'; + const currentRow = + midElement && rowIndex + 1 >= midElement.start + ? rowIndex + 1 - midElement.start + : rowIndex + 1; + + for (let cellIndex = 0; cellIndex < row.length; cellIndex++) { + const cell = row[cellIndex]; + + if (typeof cell == 'object') { + tdCount++; + + if ( + rowIndex >= firstCell.row && + rowIndex <= lastCell.row && + cellIndex >= firstCell.col && + cellIndex <= lastCell.col + ) { + const selector = `${tableSelector}${middleElSelector} tr:nth-child(${currentRow})>${cell.tagName}:nth-child(${tdCount})`; + + selectors.push(selector, selector + ' *'); + } + } + } + }); + + return selectors; +} diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/toggleTableSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/toggleTableSelection.ts new file mode 100644 index 000000000000..9941fa8e75d9 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/toggleTableSelection.ts @@ -0,0 +1,33 @@ +import { parseTableCells } from 'roosterjs-content-model-dom'; +import { removeTableCellsStyle, setTableCellsStyle } from './setTableCellsStyle'; + +import type { EditorCore, TableCellCoordinate } from 'roosterjs-content-model-types'; + +/** + * @internal + * Toggle table selection styles on/off + * @param core The EditorCore object + * @param isHiding True to hide the table selection background, false to show it + */ +export function toggleTableSelection(core: EditorCore, isHiding: boolean) { + const selection = core.selection.selection; + + if (selection?.type === 'table') { + if (isHiding) { + removeTableCellsStyle(core); + } else { + const { table, firstColumn, firstRow, lastColumn, lastRow } = selection; + const parsedTable = parseTableCells(table); + const firstCell: TableCellCoordinate = { + row: Math.min(firstRow, lastRow), + col: Math.min(firstColumn, lastColumn), + }; + const lastCell: TableCellCoordinate = { + row: Math.max(firstRow, lastRow), + col: Math.max(firstColumn, lastColumn), + }; + + setTableCellsStyle(core, table, parsedTable, firstCell, lastCell); + } + } +} diff --git a/packages/roosterjs-content-model-core/lib/coreApi/switchShadowEdit/switchShadowEdit.ts b/packages/roosterjs-content-model-core/lib/coreApi/switchShadowEdit/switchShadowEdit.ts index 4e21ae2982bd..0f707935d93c 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/switchShadowEdit/switchShadowEdit.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/switchShadowEdit/switchShadowEdit.ts @@ -1,5 +1,6 @@ import { iterateSelections, moveChildNodes } from 'roosterjs-content-model-dom'; import { toggleCaret } from '../setDOMSelection/toggleCaret'; +import { toggleTableSelection } from '../setDOMSelection/toggleTableSelection'; import type { SwitchShadowEdit } from 'roosterjs-content-model-types'; /** @@ -34,12 +35,14 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { } toggleCaret(core, true /* hide */); + toggleTableSelection(core, true /* hide */); core.lifecycle.shadowEditFragment = fragment; } else { core.lifecycle.shadowEditFragment = null; toggleCaret(core, false /* hide */); + toggleTableSelection(core, false /* hide */); core.api.triggerEvent( core, 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 90b37d6af95a..922817090436 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -520,6 +520,9 @@ class SelectionPlugin implements PluginWithState { break; } } + if (this.editor.getSnapshotsManager().hasNewContent) { + this.editor.takeSnapshot(); + } } else { this.state.tableSelection = null; } diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index 106ca6fb19bf..a0fe1140632b 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -310,7 +310,8 @@ export class Editor implements IEditor { core.darkColorHandler, { tableBorders: this.isExperimentalFeatureEnabled('TransformTableBorderColors'), - } + }, + core.format.defaultFormat.textColor ); core.lifecycle.isDarkMode = !!isDarkMode; diff --git a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setTableCellsStyleTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setTableCellsStyleTest.ts new file mode 100644 index 000000000000..15e6a53021fe --- /dev/null +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setTableCellsStyleTest.ts @@ -0,0 +1,338 @@ +import { setTableCellsStyle } from '../../../lib/coreApi/setDOMSelection/setTableCellsStyle'; +import { + EditorCore, + ParsedTable, + ParsedTableCell, + TableCellCoordinate, +} from 'roosterjs-content-model-types'; + +const DOM_SELECTION_CSS_KEY = '_DOMSelection'; +const DEFAULT_SELECTION_COLOR = '#C6C6C6'; +const DEFAULT_SELECTION_COLOR_DARK = '#666666'; + +describe('setTableCellsStyle', () => { + let core: EditorCore; + let setEditorStyleSpy: jasmine.Spy; + + beforeEach(() => { + setEditorStyleSpy = jasmine.createSpy('setEditorStyle'); + core = { + api: { + setEditorStyle: setEditorStyleSpy, + }, + lifecycle: { + isDarkMode: false, + }, + selection: { + tableCellSelectionBackgroundColor: DEFAULT_SELECTION_COLOR, + tableCellSelectionBackgroundColorDark: DEFAULT_SELECTION_COLOR_DARK, + }, + } as any; + }); + + function createTable(html: string): HTMLTableElement { + const div = document.createElement('div'); + div.innerHTML = html; + return div.querySelector('table') as HTMLTableElement; + } + + function createParsedTable(table: HTMLTableElement): ParsedTable { + const parsedTable: ParsedTable = []; + const rows = table.rows; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const parsedRow: ParsedTableCell[] = []; + + for (let j = 0; j < row.cells.length; j++) { + parsedRow.push(row.cells[j]); + } + + parsedTable.push(parsedRow); + } + + return parsedTable; + } + + it('should apply style to a single cell', () => { + const table = createTable(` + + + + + +
A1B1
A2B2
+ `); + const parsedTable = createParsedTable(table); + const firstCell: TableCellCoordinate = { row: 0, col: 0 }; + const lastCell: TableCellCoordinate = { row: 0, col: 0 }; + + setTableCellsStyle(core, table, parsedTable, firstCell, lastCell); + + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + DOM_SELECTION_CSS_KEY, + `background-color:${DEFAULT_SELECTION_COLOR}!important;`, + [ + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(1)', + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(1) *', + ] + ); + }); + + it('should apply style to multiple cells in a row', () => { + const table = createTable(` + + + + + +
A1B1C1
A2B2C2
+ `); + const parsedTable = createParsedTable(table); + const firstCell: TableCellCoordinate = { row: 0, col: 0 }; + const lastCell: TableCellCoordinate = { row: 0, col: 2 }; + + setTableCellsStyle(core, table, parsedTable, firstCell, lastCell); + + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + DOM_SELECTION_CSS_KEY, + `background-color:${DEFAULT_SELECTION_COLOR}!important;`, + [ + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(1)', + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(1) *', + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(2)', + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(2) *', + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(3)', + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(3) *', + ] + ); + }); + + it('should apply style to multiple cells in a column', () => { + const table = createTable(` + + + + + + +
A1B1
A2B2
A3B3
+ `); + const parsedTable = createParsedTable(table); + const firstCell: TableCellCoordinate = { row: 0, col: 0 }; + const lastCell: TableCellCoordinate = { row: 2, col: 0 }; + + setTableCellsStyle(core, table, parsedTable, firstCell, lastCell); + + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + DOM_SELECTION_CSS_KEY, + `background-color:${DEFAULT_SELECTION_COLOR}!important;`, + [ + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(1)', + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(1) *', + '#testTable>TBODY> tr:nth-child(2)>TD:nth-child(1)', + '#testTable>TBODY> tr:nth-child(2)>TD:nth-child(1) *', + '#testTable>TBODY> tr:nth-child(3)>TD:nth-child(1)', + '#testTable>TBODY> tr:nth-child(3)>TD:nth-child(1) *', + ] + ); + }); + + it('should apply style to a rectangular selection', () => { + const table = createTable(` + + + + + + +
A1B1C1
A2B2C2
A3B3C3
+ `); + const parsedTable = createParsedTable(table); + const firstCell: TableCellCoordinate = { row: 0, col: 1 }; + const lastCell: TableCellCoordinate = { row: 1, col: 2 }; + + setTableCellsStyle(core, table, parsedTable, firstCell, lastCell); + + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + DOM_SELECTION_CSS_KEY, + `background-color:${DEFAULT_SELECTION_COLOR}!important;`, + [ + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(2)', + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(2) *', + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(3)', + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(3) *', + '#testTable>TBODY> tr:nth-child(2)>TD:nth-child(2)', + '#testTable>TBODY> tr:nth-child(2)>TD:nth-child(2) *', + '#testTable>TBODY> tr:nth-child(2)>TD:nth-child(3)', + '#testTable>TBODY> tr:nth-child(2)>TD:nth-child(3) *', + ] + ); + }); + + it('should use table selector for full table selection', () => { + const table = createTable(` + + + + + +
A1B1
A2B2
+ `); + const parsedTable = createParsedTable(table); + const firstCell: TableCellCoordinate = { row: 0, col: 0 }; + const lastCell: TableCellCoordinate = { row: 1, col: 1 }; + + setTableCellsStyle(core, table, parsedTable, firstCell, lastCell); + + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + DOM_SELECTION_CSS_KEY, + `background-color:${DEFAULT_SELECTION_COLOR}!important;`, + ['#testTable', '#testTable *'] + ); + }); + + it('should handle table with thead and tbody', () => { + const table = createTable(` + + + + + + + + +
H1H2
A1B1
A2B2
+ `); + const parsedTable = createParsedTable(table); + const firstCell: TableCellCoordinate = { row: 0, col: 0 }; + const lastCell: TableCellCoordinate = { row: 0, col: 1 }; + + setTableCellsStyle(core, table, parsedTable, firstCell, lastCell); + + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + DOM_SELECTION_CSS_KEY, + `background-color:${DEFAULT_SELECTION_COLOR}!important;`, + [ + '#testTable>THEAD> tr:nth-child(1)>TH:nth-child(1)', + '#testTable>THEAD> tr:nth-child(1)>TH:nth-child(1) *', + '#testTable>THEAD> tr:nth-child(1)>TH:nth-child(2)', + '#testTable>THEAD> tr:nth-child(1)>TH:nth-child(2) *', + ] + ); + }); + + it('should handle table with thead, tbody and tfoot - select from tbody', () => { + const table = document.createElement('table'); + table.id = 'testTable'; + + const thead = document.createElement('thead'); + const theadRow = document.createElement('tr'); + theadRow.innerHTML = 'H1H2'; + thead.appendChild(theadRow); + + const tbody = document.createElement('tbody'); + const tbodyRow = document.createElement('tr'); + tbodyRow.innerHTML = 'A1B1'; + tbody.appendChild(tbodyRow); + + const tfoot = document.createElement('tfoot'); + const tfootRow = document.createElement('tr'); + tfootRow.innerHTML = 'F1F2'; + tfoot.appendChild(tfootRow); + + table.appendChild(thead); + table.appendChild(tbody); + table.appendChild(tfoot); + + const parsedTable = createParsedTable(table); + const firstCell: TableCellCoordinate = { row: 1, col: 0 }; + const lastCell: TableCellCoordinate = { row: 1, col: 1 }; + + setTableCellsStyle(core, table, parsedTable, firstCell, lastCell); + + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + DOM_SELECTION_CSS_KEY, + `background-color:${DEFAULT_SELECTION_COLOR}!important;`, + [ + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(1)', + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(1) *', + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(2)', + '#testTable>TBODY> tr:nth-child(1)>TD:nth-child(2) *', + ] + ); + }); + + it('should use dark mode color when isDarkMode is true', () => { + core.lifecycle.isDarkMode = true; + const table = createTable(` + + + + + +
A1B1
A2B2
+ `); + const parsedTable = createParsedTable(table); + const firstCell: TableCellCoordinate = { row: 0, col: 0 }; + const lastCell: TableCellCoordinate = { row: 1, col: 1 }; + + setTableCellsStyle(core, table, parsedTable, firstCell, lastCell); + + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + DOM_SELECTION_CSS_KEY, + `background-color:${DEFAULT_SELECTION_COLOR_DARK}!important;`, + ['#testTable', '#testTable *'] + ); + }); + + it('should handle selection outside table bounds', () => { + const table = createTable(` + + + + +
A1B1
+ `); + const parsedTable = createParsedTable(table); + const firstCell: TableCellCoordinate = { row: 5, col: 5 }; + const lastCell: TableCellCoordinate = { row: 6, col: 6 }; + + setTableCellsStyle(core, table, parsedTable, firstCell, lastCell); + + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + DOM_SELECTION_CSS_KEY, + `background-color:${DEFAULT_SELECTION_COLOR}!important;`, + [] + ); + }); + + it('should generate unique id for table without id', () => { + const table = createTable(` + + + + + +
A1B1
A2B2
+ `); + const parsedTable = createParsedTable(table); + const firstCell: TableCellCoordinate = { row: 0, col: 0 }; + const lastCell: TableCellCoordinate = { row: 1, col: 1 }; + + setTableCellsStyle(core, table, parsedTable, firstCell, lastCell); + + expect(setEditorStyleSpy).toHaveBeenCalled(); + expect(table.id).toBeTruthy(); + expect(table.id.startsWith('table')).toBeTrue(); + }); +}); diff --git a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/toggleTableSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/toggleTableSelectionTest.ts new file mode 100644 index 000000000000..143e76fd2c2a --- /dev/null +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/toggleTableSelectionTest.ts @@ -0,0 +1,230 @@ +import { EditorCore, TableSelection } from 'roosterjs-content-model-types'; +import { toggleTableSelection } from '../../../lib/coreApi/setDOMSelection/toggleTableSelection'; + +const DOM_SELECTION_CSS_KEY = '_DOMSelection'; + +describe('toggleTableSelection', () => { + let core: EditorCore; + let setEditorStyleSpy: jasmine.Spy; + + beforeEach(() => { + setEditorStyleSpy = jasmine.createSpy('setEditorStyle'); + + core = { + selection: { + selection: null, + tableCellSelectionBackgroundColor: '#C6C6C6', + tableCellSelectionBackgroundColorDark: '#666666', + }, + api: { + setEditorStyle: setEditorStyleSpy, + }, + lifecycle: { + isDarkMode: false, + }, + } as any; + }); + + it('should do nothing when selection is null', () => { + core.selection.selection = null; + + toggleTableSelection(core, true); + + expect(setEditorStyleSpy).not.toHaveBeenCalled(); + }); + + it('should do nothing when selection is not table type', () => { + core.selection.selection = { + type: 'range', + range: {} as any, + isReverted: false, + }; + + toggleTableSelection(core, true); + + expect(setEditorStyleSpy).not.toHaveBeenCalled(); + }); + + it('should hide table selection when isHiding is true', () => { + const table = document.createElement('table'); + table.id = 'testTable'; + table.innerHTML = ` + + A1B1 + A2B2 + + `; + document.body.appendChild(table); + + const tableSelection: TableSelection = { + type: 'table', + table: table, + firstRow: 0, + firstColumn: 0, + lastRow: 1, + lastColumn: 1, + }; + core.selection.selection = tableSelection; + + toggleTableSelection(core, true); + + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, DOM_SELECTION_CSS_KEY, ''); + + document.body.removeChild(table); + }); + + it('should show table selection when isHiding is false', () => { + const table = document.createElement('table'); + table.id = 'testTable'; + table.innerHTML = ` + + A1B1 + A2B2 + + `; + document.body.appendChild(table); + + const tableSelection: TableSelection = { + type: 'table', + table: table, + firstRow: 0, + firstColumn: 0, + lastRow: 1, + lastColumn: 1, + }; + core.selection.selection = tableSelection; + + toggleTableSelection(core, false); + + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + DOM_SELECTION_CSS_KEY, + 'background-color:#C6C6C6!important;', + ['#testTable', '#testTable *'] + ); + + document.body.removeChild(table); + }); + + it('should use dark mode color when isDarkMode is true', () => { + const table = document.createElement('table'); + table.id = 'testTable'; + table.innerHTML = ` + + A1B1 + A2B2 + + `; + document.body.appendChild(table); + + core.lifecycle.isDarkMode = true; + const tableSelection: TableSelection = { + type: 'table', + table: table, + firstRow: 0, + firstColumn: 0, + lastRow: 1, + lastColumn: 1, + }; + core.selection.selection = tableSelection; + + toggleTableSelection(core, false); + + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + DOM_SELECTION_CSS_KEY, + 'background-color:#666666!important;', + ['#testTable', '#testTable *'] + ); + + document.body.removeChild(table); + }); + + it('should use buildTableSelectors for partial table selection', () => { + const table = document.createElement('table'); + table.id = 'testTable'; + table.innerHTML = ` + + A1B1C1 + A2B2C2 + A3B3C3 + + `; + document.body.appendChild(table); + + const tableSelection: TableSelection = { + type: 'table', + table: table, + firstRow: 0, + firstColumn: 0, + lastRow: 0, + lastColumn: 0, + }; + core.selection.selection = tableSelection; + + toggleTableSelection(core, true); + + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, DOM_SELECTION_CSS_KEY, ''); + + document.body.removeChild(table); + }); + + it('should handle reversed selection coordinates', () => { + const table = document.createElement('table'); + table.id = 'testTable'; + table.innerHTML = ` + + A1B1 + A2B2 + + `; + document.body.appendChild(table); + + // Selection with reversed coordinates (lastRow < firstRow) + const tableSelection: TableSelection = { + type: 'table', + table: table, + firstRow: 1, + firstColumn: 1, + lastRow: 0, + lastColumn: 0, + }; + core.selection.selection = tableSelection; + + toggleTableSelection(core, true); + + // Should still work because the function normalizes coordinates + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, DOM_SELECTION_CSS_KEY, ''); + + document.body.removeChild(table); + }); + + it('should ensure table has unique id', () => { + const table = document.createElement('table'); + // No id set initially + table.innerHTML = ` + + A1B1 + A2B2 + + `; + document.body.appendChild(table); + + const tableSelection: TableSelection = { + type: 'table', + table: table, + firstRow: 0, + firstColumn: 0, + lastRow: 1, + lastColumn: 1, + }; + core.selection.selection = tableSelection; + + toggleTableSelection(core, true); + + // Table should not need an id when hiding (removeTableCellsStyle doesn't use selectors) + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, DOM_SELECTION_CSS_KEY, ''); + + document.body.removeChild(table); + }); +}); diff --git a/packages/roosterjs-content-model-core/test/coreApi/switchShadowEdit/switchShadowEditTest.ts b/packages/roosterjs-content-model-core/test/coreApi/switchShadowEdit/switchShadowEditTest.ts index 5d223b3d9e86..37deb0e95fed 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/switchShadowEdit/switchShadowEditTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/switchShadowEdit/switchShadowEditTest.ts @@ -1,5 +1,6 @@ import * as iterateSelections from 'roosterjs-content-model-dom/lib/modelApi/selection/iterateSelections'; import * as toggleCaret from '../../../lib/coreApi/setDOMSelection/toggleCaret'; +import * as toggleTableSelection from '../../../lib/coreApi/setDOMSelection/toggleTableSelection'; import { EditorCore } from 'roosterjs-content-model-types'; import { switchShadowEdit } from '../../../lib/coreApi/switchShadowEdit/switchShadowEdit'; @@ -13,6 +14,7 @@ describe('switchShadowEdit', () => { let getSelectionRange: jasmine.Spy; let triggerEvent: jasmine.Spy; let toggleCaretSpy: jasmine.Spy; + let toggleTableSelectionSpy: jasmine.Spy; beforeEach(() => { createContentModel = jasmine.createSpy('createContentModel').and.returnValue(mockedModel); @@ -20,6 +22,7 @@ describe('switchShadowEdit', () => { getSelectionRange = jasmine.createSpy('getSelectionRange'); triggerEvent = jasmine.createSpy('triggerEvent'); toggleCaretSpy = spyOn(toggleCaret, 'toggleCaret'); + toggleTableSelectionSpy = spyOn(toggleTableSelection, 'toggleTableSelection'); const contentDiv = document.createElement('div'); @@ -34,6 +37,9 @@ describe('switchShadowEdit', () => { }, lifecycle: {}, cache: {}, + selection: { + selection: null, + }, } as any) as EditorCore; }); @@ -54,6 +60,7 @@ describe('switchShadowEdit', () => { false ); expect(toggleCaretSpy).toHaveBeenCalledWith(core, true); + expect(toggleTableSelectionSpy).toHaveBeenCalledWith(core, true); }); it('with cache, isOn', () => { @@ -74,6 +81,7 @@ describe('switchShadowEdit', () => { false ); expect(toggleCaretSpy).toHaveBeenCalledWith(core, true); + expect(toggleTableSelectionSpy).toHaveBeenCalledWith(core, true); }); it('no cache, isOff', () => { @@ -85,6 +93,7 @@ describe('switchShadowEdit', () => { expect(triggerEvent).not.toHaveBeenCalled(); expect(toggleCaretSpy).not.toHaveBeenCalled(); + expect(toggleTableSelectionSpy).not.toHaveBeenCalled(); }); it('with cache, isOff', () => { @@ -98,6 +107,7 @@ describe('switchShadowEdit', () => { expect(triggerEvent).not.toHaveBeenCalled(); expect(toggleCaretSpy).not.toHaveBeenCalled(); + expect(toggleTableSelectionSpy).not.toHaveBeenCalled(); }); }); @@ -115,6 +125,7 @@ describe('switchShadowEdit', () => { expect(triggerEvent).not.toHaveBeenCalled(); expect(toggleCaretSpy).not.toHaveBeenCalled(); + expect(toggleTableSelectionSpy).not.toHaveBeenCalled(); }); it('with cache, isOn', () => { @@ -128,6 +139,7 @@ describe('switchShadowEdit', () => { expect(triggerEvent).not.toHaveBeenCalled(); expect(toggleCaretSpy).not.toHaveBeenCalled(); + expect(toggleTableSelectionSpy).not.toHaveBeenCalled(); }); it('no cache, isOff', () => { @@ -146,6 +158,7 @@ describe('switchShadowEdit', () => { false ); expect(toggleCaretSpy).toHaveBeenCalledWith(core, false); + expect(toggleTableSelectionSpy).toHaveBeenCalledWith(core, false); }); it('with cache, isOff', () => { @@ -170,6 +183,7 @@ describe('switchShadowEdit', () => { false ); expect(toggleCaretSpy).toHaveBeenCalledWith(core, false); + expect(toggleTableSelectionSpy).toHaveBeenCalledWith(core, false); }); }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/CopyPastePluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/CopyPastePluginTest.ts index a36b8fcdab16..8589b1782d51 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/CopyPastePluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/CopyPastePluginTest.ts @@ -10,7 +10,7 @@ import { adjustSelectionForCopyCut } from '../../../lib/command/cutCopy/adjustSe import { createModelToDomContext, createTable, createTableCell } from 'roosterjs-content-model-dom'; import { createRange } from 'roosterjs-content-model-dom/test/testUtils'; import { onNodeCreated } from '../../../lib/command/cutCopy/getContentForCopy'; -import { preprocessTable } from '../../../lib/command/cutCopy/preprocessTable'; +import { preprocessTable } from 'roosterjs-content-model-dom/lib/domUtils/selection/preprocessTable'; import { setEntityElementClasses } from 'roosterjs-content-model-dom/test/domUtils/entityUtilTest'; import { ContentModelDocument, 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 e565ddf5aa62..53f37e762297 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -782,6 +782,9 @@ describe('SelectionPlugin handle table selection', () => { isExperimentalFeatureEnabled: () => { return false; }, + getSnapshotsManager: () => { + return { hasNewContent: false }; + }, } as any; plugin = createSelectionPlugin({}); plugin.initialize(editor); @@ -1842,6 +1845,134 @@ describe('SelectionPlugin handle table selection', () => { expect(time).toBe(2); }); + it('From Range, Press Tab - take undo snapshot when hasNewContent', () => { + let time = 0; + getDOMSelectionSpy.and.callFake(() => { + time++; + + return time == 1 + ? { + type: 'range', + range: { + startContainer: td1, + startOffset: 0, + endContainer: td1, + endOffset: 0, + commonAncestorContainer: tr1, + }, + isReverted: false, + } + : { + type: 'range', + range: { + startContainer: td1, + startOffset: 0, + endContainer: td1, + endOffset: 0, + commonAncestorContainer: tr1, + collapsed: true, + }, + isReverted: false, + }; + }); + + const setStartSpy = jasmine.createSpy('setStart'); + const setEndSpy = jasmine.createSpy('setEnd'); + const collapseSpy = jasmine.createSpy('collapse'); + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const mockedRange = { + setStart: setStartSpy, + setEnd: setEndSpy, + collapse: collapseSpy, + } as any; + const takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); + const getSnapshotsManagerSpy = jasmine + .createSpy('getSnapshotsManager') + .and.returnValue({ + hasNewContent: true, + }); + + (editor as any).getSnapshotsManager = getSnapshotsManagerSpy; + (editor as any).takeSnapshot = takeSnapshotSpy; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'Tab', + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(takeSnapshotSpy).toHaveBeenCalledTimes(1); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + }); + + it('From Range, Press Tab - do not take undo snapshot when no new content', () => { + let time = 0; + getDOMSelectionSpy.and.callFake(() => { + time++; + + return time == 1 + ? { + type: 'range', + range: { + startContainer: td1, + startOffset: 0, + endContainer: td1, + endOffset: 0, + commonAncestorContainer: tr1, + }, + isReverted: false, + } + : { + type: 'range', + range: { + startContainer: td1, + startOffset: 0, + endContainer: td1, + endOffset: 0, + commonAncestorContainer: tr1, + collapsed: true, + }, + isReverted: false, + }; + }); + + const setStartSpy = jasmine.createSpy('setStart'); + const setEndSpy = jasmine.createSpy('setEnd'); + const collapseSpy = jasmine.createSpy('collapse'); + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const mockedRange = { + setStart: setStartSpy, + setEnd: setEndSpy, + collapse: collapseSpy, + } as any; + const takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); + const getSnapshotsManagerSpy = jasmine + .createSpy('getSnapshotsManager') + .and.returnValue({ + hasNewContent: false, + }); + + (editor as any).getSnapshotsManager = getSnapshotsManagerSpy; + (editor as any).takeSnapshot = takeSnapshotSpy; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'Tab', + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(takeSnapshotSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + }); + it('From Range, Press Down', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', diff --git a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index a646bd835d54..c6ff9050e646 100644 --- a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -1016,7 +1016,8 @@ describe('Editor', () => { mockedColorHandler, { tableBorders: false, - } + }, + undefined ); expect(mockedCore.lifecycle.isDarkMode).toEqual(true); expect(triggerEventSpy).toHaveBeenCalledTimes(1); @@ -1041,7 +1042,8 @@ describe('Editor', () => { mockedColorHandler, { tableBorders: false, - } + }, + undefined ); expect(triggerEventSpy).toHaveBeenCalledTimes(2); expect(triggerEventSpy).toHaveBeenCalledWith( diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor.ts b/packages/roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor.ts index 5fefd5e831d0..3a978f5b3c38 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor.ts @@ -71,6 +71,7 @@ export const tableProcessor: ElementProcessor = ( const hasColGroup = processColGroup(tableElement, context, columnPositions); const rowPositions: number[] = [0]; const zoomScale = context.zoomScale || 1; + let maxColumns = 0; for (let row = 0; row < tableElement.rows.length; row++) { const tr = tableElement.rows[row]; @@ -275,17 +276,37 @@ export const tableProcessor: ElementProcessor = ( ); } } + + maxColumns = Math.max(maxColumns, tableRow.cells.length); } table.widths = calcSizes(columnPositions); const heights = calcSizes(rowPositions); - table.rows.forEach((row, i) => { + for (let i = 0; i < table.rows.length; i++) { + const row = table.rows[i]; + const currentLength = row.cells.length; + + if (currentLength > 0 && currentLength < maxColumns) { + const lastCell = row.cells[currentLength - 1]; + + for (let col = currentLength; col < maxColumns; col++) { + const spanCell = createTableCell( + true, // spanLeft + false, + lastCell.isHeader, + lastCell.format + ); + spanCell.dataset = { ...lastCell.dataset }; + row.cells[col] = spanCell; + } + } + if (heights[i] > 0) { row.height = heights[i]; } - }); + } } ); }; diff --git a/packages/roosterjs-content-model-core/lib/command/cutCopy/preprocessTable.ts b/packages/roosterjs-content-model-dom/lib/domUtils/selection/preprocessTable.ts similarity index 88% rename from packages/roosterjs-content-model-core/lib/command/cutCopy/preprocessTable.ts rename to packages/roosterjs-content-model-dom/lib/domUtils/selection/preprocessTable.ts index e023cc95f585..23f879f7fcf1 100644 --- a/packages/roosterjs-content-model-core/lib/command/cutCopy/preprocessTable.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/selection/preprocessTable.ts @@ -1,4 +1,4 @@ -import { getSelectedCells } from 'roosterjs-content-model-dom'; +import { getSelectedCells } from '../../modelApi/selection/getSelectedCells'; import type { ContentModelTable } from 'roosterjs-content-model-types'; /** diff --git a/packages/roosterjs-content-model-core/lib/command/cutCopy/pruneUnselectedModel.ts b/packages/roosterjs-content-model-dom/lib/domUtils/selection/pruneUnselectedModel.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/command/cutCopy/pruneUnselectedModel.ts rename to packages/roosterjs-content-model-dom/lib/domUtils/selection/pruneUnselectedModel.ts diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/selection/trimModelForSelection.ts b/packages/roosterjs-content-model-dom/lib/domUtils/selection/trimModelForSelection.ts new file mode 100644 index 000000000000..c801a94deae2 --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/domUtils/selection/trimModelForSelection.ts @@ -0,0 +1,24 @@ +import { iterateSelections } from '../../modelApi/selection/iterateSelections'; +import { preprocessTable } from './preprocessTable'; +import { pruneUnselectedModel } from './pruneUnselectedModel'; +import type { ContentModelDocument, DOMSelection } from 'roosterjs-content-model-types'; + +/** + * Remove the unselected content from the model + * @param model the model document + * @param selection The editor selection + * */ +export function trimModelForSelection(model: ContentModelDocument, selection: DOMSelection) { + pruneUnselectedModel(model); + + if (selection.type === 'table') { + iterateSelections(model, (_, tableContext) => { + if (tableContext?.table) { + preprocessTable(tableContext.table); + + return true; + } + return false; + }); + } +} diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/style/transformColor.ts b/packages/roosterjs-content-model-dom/lib/domUtils/style/transformColor.ts index 5a22a54de6b6..709ea5e01141 100644 --- a/packages/roosterjs-content-model-dom/lib/domUtils/style/transformColor.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/style/transformColor.ts @@ -30,23 +30,34 @@ export function transformColor( includeSelf: boolean, direction: 'lightToDark' | 'darkToLight', darkColorHandler?: DarkColorHandler, - transformColorOptions?: TransformColorOptions + transformColorOptions?: TransformColorOptions, + defaultTextColor?: string ) { const toDarkMode = direction == 'lightToDark'; const tableBorders = transformColorOptions?.tableBorders || false; - const transformer = (element: HTMLElement) => { + const transformer = (element: HTMLElement, parentTextColor?: string) => { const textColor = getColor(element, false /*isBackground*/, !toDarkMode, darkColorHandler); const backColor = getColor(element, true /*isBackground*/, !toDarkMode, darkColorHandler); + const comparingColor = textColor || parentTextColor; setColor(element, textColor, false /*isBackground*/, toDarkMode, darkColorHandler); - setColor(element, backColor, true /*isBackground*/, toDarkMode, darkColorHandler); + setColor( + element, + backColor, + true /*isBackground*/, + toDarkMode, + darkColorHandler, + comparingColor + ); if (tableBorders) { transformBorderColor(element, toDarkMode, darkColorHandler); } + + return comparingColor; }; - iterateElements(rootNode, transformer, includeSelf); + iterateElements(rootNode, transformer, includeSelf, defaultTextColor); } function transformBorderColor( @@ -79,19 +90,22 @@ function transformBorderColor( function iterateElements( root: Node, - transformer: (element: HTMLElement) => void, - includeSelf?: boolean + transformer: (element: HTMLElement, parentTextColor?: string) => string | undefined, + includeSelf?: boolean, + parentTextColor?: string ) { if (includeSelf && isHTMLElement(root)) { - transformer(root); + parentTextColor = transformer(root, parentTextColor); } for (let child = root.firstChild; child; child = child.nextSibling) { + let textColor = parentTextColor; + if (isHTMLElement(child)) { - transformer(child); + textColor = transformer(child, parentTextColor); } - iterateElements(child, transformer); + iterateElements(child, transformer, false, textColor); } } diff --git a/packages/roosterjs-content-model-dom/lib/formatHandlers/common/backgroundColorFormatHandler.ts b/packages/roosterjs-content-model-dom/lib/formatHandlers/common/backgroundColorFormatHandler.ts index a2d7577f254b..f81f7af323f0 100644 --- a/packages/roosterjs-content-model-dom/lib/formatHandlers/common/backgroundColorFormatHandler.ts +++ b/packages/roosterjs-content-model-dom/lib/formatHandlers/common/backgroundColorFormatHandler.ts @@ -1,12 +1,14 @@ import { getColor, setColor } from '../utils/color'; import { shouldSetValue } from '../utils/shouldSetValue'; -import type { BackgroundColorFormat } from 'roosterjs-content-model-types'; +import type { BackgroundColorFormat, TextColorFormat } from 'roosterjs-content-model-types'; import type { FormatHandler } from '../FormatHandler'; /** * @internal */ -export const backgroundColorFormatHandler: FormatHandler = { +export const backgroundColorFormatHandler: FormatHandler< + BackgroundColorFormat & TextColorFormat +> = { parse: (format, element, context, defaultStyle) => { const backgroundColor = getColor( @@ -34,7 +36,8 @@ export const backgroundColorFormatHandler: FormatHandler format.backgroundColor, true /*isBackground*/, !!context.isDarkMode, - context.darkColorHandler + context.darkColorHandler, + format.textColor ); } }, diff --git a/packages/roosterjs-content-model-dom/lib/formatHandlers/utils/color.ts b/packages/roosterjs-content-model-dom/lib/formatHandlers/utils/color.ts index eb68b5fe7858..a4c4af14f89b 100644 --- a/packages/roosterjs-content-model-dom/lib/formatHandlers/utils/color.ts +++ b/packages/roosterjs-content-model-dom/lib/formatHandlers/utils/color.ts @@ -84,12 +84,12 @@ export function getLightModeColor( ) { if (DeprecatedColors.indexOf(color) > -1) { return fallback; - } else if (darkColorHandler) { + } else { const match = color.startsWith(VARIABLE_PREFIX) ? VARIABLE_REGEX.exec(color) : null; if (match) { color = match[2] || ''; - } else if (isDarkMode) { + } else if (isDarkMode && darkColorHandler) { // If editor is in dark mode but the color is not in dark color format, it is possible the color was inserted from external code // without any light color info. So we first try to see if there is a known dark color can match this color, and use its related // light color as light mode color. Otherwise we need to drop this color to avoid show "white on white" content. @@ -126,20 +126,23 @@ export function retrieveElementColor( * @param isBackground True to set background color, false to set text color * @param isDarkMode Whether element is in dark mode now * @param darkColorHandler @optional The dark color handler object to help manager dark mode color + * @param comparingColor @optional When generating dark color for background color, we can provide text color as comparingColor to make sure the generated dark border color has enough contrast with text color in dark mode */ export function setColor( element: HTMLElement, color: string | null | undefined, isBackground: boolean, isDarkMode: boolean, - darkColorHandler?: DarkColorHandler + darkColorHandler?: DarkColorHandler, + comparingColor?: string ) { const newColor = adaptColor( element, color, isBackground ? 'background' : 'text', isDarkMode, - darkColorHandler + darkColorHandler, + comparingColor ); element.removeAttribute(isBackground ? 'bgcolor' : 'color'); @@ -154,7 +157,8 @@ export function adaptColor( color: string | null | undefined, colorType: 'text' | 'background' | 'border', isDarkMode: boolean, - darkColorHandler?: DarkColorHandler + darkColorHandler?: DarkColorHandler, + comparingColor?: string ) { const match = color && color.startsWith(VARIABLE_PREFIX) ? VARIABLE_REGEX.exec(color) : null; const [_, existingKey, fallbackColor] = match ?? []; @@ -164,10 +168,22 @@ export function adaptColor( if (darkColorHandler && color) { const key = existingKey || - darkColorHandler.generateColorKey(color, undefined /*baseLValue*/, colorType, element); + darkColorHandler.generateColorKey( + color, + undefined /*baseLValue*/, + colorType, + element, + comparingColor + ); const darkModeColor = darkColorHandler.knownColors?.[key]?.darkModeColor || - darkColorHandler.getDarkColor(color, undefined /*baseLValue*/, colorType, element); + darkColorHandler.getDarkColor( + color, + undefined /*baseLValue*/, + colorType, + element, + comparingColor + ); darkColorHandler.updateKnownColor(isDarkMode, key, { lightModeColor: color, @@ -185,8 +201,15 @@ export function adaptColor( * @param lightColor The input light color * @returns Key of the color */ -export const defaultGenerateColorKey: ColorTransformFunction = lightColor => { - return `${COLOR_VAR_PREFIX}_${lightColor.replace(/[^\d\w]/g, '_')}`; +export const defaultGenerateColorKey: ColorTransformFunction = ( + lightColor, + _1, + _2, + _3, + comparingColor +) => { + const comparingColorKey = comparingColor ? `_${comparingColor.replace(/[^\d\w]/g, '_')}` : ''; + return `${COLOR_VAR_PREFIX}_${lightColor.replace(/[^\d\w]/g, '_')}${comparingColorKey}`; }; /** diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index 6a3fb64f4760..1b81b754707d 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -113,6 +113,7 @@ export { export { isBold } from './domUtils/style/isBold'; 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 { combineBorderValue, extractBorderValues } from './domUtils/style/borderValues'; export { isPunctuation, isSpace, normalizeText } from './domUtils/stringUtil'; 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..33cba790a24c 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/lib/modelToDom/handlers/handleList.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleList.ts index f524d3917cfd..90105c7401fc 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleList.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleList.ts @@ -43,6 +43,9 @@ export const handleList: ContentModelBlockHandler = ( itemLevel.format.listStyleType != stackLevel.format?.listStyleType) ) { break; + } else if (itemLevel.listType == 'UL') { + // Apply metadata to list level to make sure list style is correct after rendering + applyMetadata(itemLevel, context.metadataAppliers.listLevel, itemLevel.format, context); } if ( diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts index 9b11ba09ebcf..94b07444b233 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts @@ -1419,6 +1419,344 @@ describe('tableProcessor', () => { ], }); }); + + it('Process a table with rows having fewer cells - extends short rows with spanLeft cells', () => { + const group = createContentModelDocument(); + const div = document.createElement('div'); + div.innerHTML = + '
'; + + tableProcessor(group, div.firstChild as HTMLTableElement, context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 200, + cells: [ + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: false, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: false, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: false, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + ], + }, + { + format: {}, + height: 200, + cells: [ + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: false, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: true, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: true, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [100, 100, 100], + dataset: {}, + }, + ], + }); + }); + + it('Process a table with header cell in short row - extends with spanLeft header cells', () => { + const group = createContentModelDocument(); + const div = document.createElement('div'); + div.innerHTML = + '
'; + + tableProcessor(group, div.firstChild as HTMLTableElement, context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 200, + cells: [ + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: false, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: false, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + ], + }, + { + format: {}, + height: 200, + cells: [ + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: false, + isHeader: true, + blocks: [], + format: {}, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: true, + isHeader: true, + blocks: [], + format: {}, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [100, 100], + dataset: {}, + }, + ], + }); + }); + + it('Process a table with colspan in first row and fewer cells in second row', () => { + const group = createContentModelDocument(); + const div = document.createElement('div'); + div.innerHTML = + '
'; + + tableProcessor(group, div.firstChild as HTMLTableElement, context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 200, + cells: [ + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: false, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: true, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: true, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + ], + }, + { + format: {}, + height: 200, + cells: [ + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: false, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: true, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: true, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [100, 0, 0], + dataset: {}, + }, + ], + }); + }); + + it('Process a table with colspan in short row', () => { + const group = createContentModelDocument(); + const div = document.createElement('div'); + div.innerHTML = + '
'; + + tableProcessor(group, div.firstChild as HTMLTableElement, context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 200, + cells: [ + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: false, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: false, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: false, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + ], + }, + { + format: {}, + height: 200, + cells: [ + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: false, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: true, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: true, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [100, 100, 100], + dataset: {}, + }, + ], + }); + }); }); describe('tableProcessor without recalculateTableSize', () => { @@ -1634,6 +1972,15 @@ describe('tableProcessor without recalculateTableSize', () => { isHeader: false, dataset: {}, }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: true, + spanAbove: false, + isHeader: false, + dataset: {}, + }, ], }, { @@ -1658,6 +2005,15 @@ describe('tableProcessor without recalculateTableSize', () => { isHeader: false, dataset: {}, }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: true, + spanAbove: false, + isHeader: false, + dataset: {}, + }, ], }, { @@ -1682,6 +2038,15 @@ describe('tableProcessor without recalculateTableSize', () => { isHeader: false, dataset: {}, }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: true, + spanAbove: false, + isHeader: false, + dataset: {}, + }, ], }, { @@ -1739,6 +2104,15 @@ describe('tableProcessor without recalculateTableSize', () => { isHeader: false, dataset: {}, }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: true, + spanAbove: false, + isHeader: false, + dataset: {}, + }, ], }, { @@ -1840,6 +2214,24 @@ describe('tableProcessor without recalculateTableSize', () => { isHeader: false, dataset: {}, }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: true, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: true, + spanAbove: false, + isHeader: false, + dataset: {}, + }, ], }, { @@ -1882,6 +2274,24 @@ describe('tableProcessor without recalculateTableSize', () => { isHeader: false, dataset: {}, }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: true, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: true, + spanAbove: false, + isHeader: false, + dataset: {}, + }, ], }, { @@ -1933,6 +2343,15 @@ describe('tableProcessor without recalculateTableSize', () => { isHeader: false, dataset: {}, }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: true, + spanAbove: false, + isHeader: false, + dataset: {}, + }, ], }, { @@ -2035,6 +2454,24 @@ describe('tableProcessor without recalculateTableSize', () => { isHeader: false, dataset: {}, }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: true, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: true, + spanAbove: false, + isHeader: false, + dataset: {}, + }, ], }, { @@ -2209,6 +2646,24 @@ describe('tableProcessor without recalculateTableSize', () => { format: {}, dataset: {}, }, + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: true, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + spanAbove: false, + spanLeft: true, + isHeader: false, + blocks: [], + format: {}, + dataset: {}, + }, ], }, // Second row - column 0 spans from above, column 3 spans from above (due to shift) diff --git a/packages/roosterjs-content-model-core/test/command/cutCopy/preprocessTableTest.ts b/packages/roosterjs-content-model-dom/test/domUtils/selection/preprocessTableTest.ts similarity index 98% rename from packages/roosterjs-content-model-core/test/command/cutCopy/preprocessTableTest.ts rename to packages/roosterjs-content-model-dom/test/domUtils/selection/preprocessTableTest.ts index bbf72b77cd0d..71a7eec9ab11 100644 --- a/packages/roosterjs-content-model-core/test/command/cutCopy/preprocessTableTest.ts +++ b/packages/roosterjs-content-model-dom/test/domUtils/selection/preprocessTableTest.ts @@ -1,4 +1,4 @@ -import { preprocessTable } from '../../../lib/command/cutCopy/preprocessTable'; +import { preprocessTable } from '../../../lib/domUtils/selection/preprocessTable'; import { createTable, createTableCell, diff --git a/packages/roosterjs-content-model-core/test/command/cutCopy/pruneUnselectedModelTest.ts b/packages/roosterjs-content-model-dom/test/domUtils/selection/pruneUnselectedModelTest.ts similarity index 99% rename from packages/roosterjs-content-model-core/test/command/cutCopy/pruneUnselectedModelTest.ts rename to packages/roosterjs-content-model-dom/test/domUtils/selection/pruneUnselectedModelTest.ts index f4c4ab327aca..f1370d81ea55 100644 --- a/packages/roosterjs-content-model-core/test/command/cutCopy/pruneUnselectedModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/domUtils/selection/pruneUnselectedModelTest.ts @@ -1,4 +1,4 @@ -import { pruneUnselectedModel } from '../../../lib/command/cutCopy/pruneUnselectedModel'; +import { pruneUnselectedModel } from '../../../lib/domUtils/selection/pruneUnselectedModel'; import { createBr, createContentModelDocument, diff --git a/packages/roosterjs-content-model-dom/test/domUtils/selection/trimModelForSelectionTest.ts b/packages/roosterjs-content-model-dom/test/domUtils/selection/trimModelForSelectionTest.ts new file mode 100644 index 000000000000..667e757b93b7 --- /dev/null +++ b/packages/roosterjs-content-model-dom/test/domUtils/selection/trimModelForSelectionTest.ts @@ -0,0 +1,340 @@ +import { ContentModelDocument, DOMSelection } from 'roosterjs-content-model-types'; +import { trimModelForSelection } from '../../../lib/domUtils/selection/trimModelForSelection'; +import { + createContentModelDocument, + createImage, + createParagraph, + createSelectionMarker, + createTable, + createTableCell, + createText, +} from 'roosterjs-content-model-dom'; + +describe('trimModelForSelection', () => { + it('should return model for non-collapsed range selection', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const text = createText('selected text'); + text.isSelected = true; + para.segments.push(text); + model.blocks.push(para); + + const mockDocument = document.implementation.createHTMLDocument('test'); + const div = mockDocument.createElement('div'); + div.textContent = 'selected text'; + mockDocument.body.appendChild(div); + + const range = mockDocument.createRange(); + range.selectNodeContents(div); + + const selection: DOMSelection = { + type: 'range', + range, + isReverted: false, + }; + + trimModelForSelection(model, selection); + + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'selected text', + format: {}, + isSelected: true, + }, + ], + isImplicit: true, + }, + ], + }; + + expect(model).toEqual(expectedModel); + }); + + it('should return model for table selection', () => { + const model = createContentModelDocument(); + const table = createTable(2); + + // Row 0 + const cell00 = createTableCell(); + const para00 = createParagraph(); + const text00 = createText('Cell 0,0'); + text00.isSelected = true; + para00.segments.push(text00); + cell00.blocks.push(para00); + cell00.isSelected = true; + + const cell01 = createTableCell(); + const para01 = createParagraph(); + const text01 = createText('Cell 0,1'); + text01.isSelected = true; + para01.segments.push(text01); + cell01.blocks.push(para01); + cell01.isSelected = true; + + table.rows[0].cells.push(cell00, cell01); + + // Row 1 + const cell10 = createTableCell(); + const para10 = createParagraph(); + const text10 = createText('Cell 1,0'); + text10.isSelected = true; + para10.segments.push(text10); + cell10.blocks.push(para10); + cell10.isSelected = true; + + const cell11 = createTableCell(); + const para11 = createParagraph(); + const text11 = createText('Cell 1,1'); + text11.isSelected = true; + para11.segments.push(text11); + cell11.blocks.push(para11); + cell11.isSelected = true; + + table.rows[1].cells.push(cell10, cell11); + model.blocks.push(table); + + const mockDocument = document.implementation.createHTMLDocument('test'); + const tableElement = mockDocument.createElement('table'); + mockDocument.body.appendChild(tableElement); + + const selection: DOMSelection = { + type: 'table', + table: tableElement, + firstColumn: 0, + lastColumn: 1, + firstRow: 0, + lastRow: 1, + }; + + trimModelForSelection(model, selection); + + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'Cell 0,0', + format: {}, + isSelected: true, + }, + ], + }, + ], + format: {}, + spanAbove: false, + spanLeft: false, + isHeader: false, + dataset: {}, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'Cell 0,1', + format: {}, + isSelected: true, + }, + ], + }, + ], + format: {}, + spanAbove: false, + spanLeft: false, + isHeader: false, + dataset: {}, + isSelected: true, + }, + ], + }, + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'Cell 1,0', + format: {}, + isSelected: true, + }, + ], + }, + ], + format: {}, + spanAbove: false, + spanLeft: false, + isHeader: false, + dataset: {}, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'Cell 1,1', + format: {}, + isSelected: true, + }, + ], + }, + ], + format: {}, + spanAbove: false, + spanLeft: false, + isHeader: false, + dataset: {}, + isSelected: true, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + }; + + expect(model).toEqual(expectedModel); + }); + + it('should return model for image selection', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const imageSelected = createImage('www.test.com'); + para.segments.push(imageSelected); + imageSelected.isSelected = true; + const marker = createSelectionMarker(); + marker.isSelected = true; + para.segments.push(marker); + model.blocks.push(para); + + const mockDocument = document.implementation.createHTMLDocument('test'); + const img = mockDocument.createElement('img'); + mockDocument.body.appendChild(img); + const range = document.createRange(); + range.selectNode(img); + + const selection: DOMSelection = { + type: 'range', + range, + isReverted: false, + }; + + trimModelForSelection(model, selection); + + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Image', + src: 'www.test.com', + dataset: {}, + format: {}, + isSelected: true, + }, + ], + isImplicit: true, + }, + ], + }; + + expect(model).toEqual(expectedModel); + }); + + it('should prune unselected content from model', () => { + const model = createContentModelDocument(); + + // Add selected paragraph + const selectedPara = createParagraph(); + const selectedText = createText('selected'); + selectedText.isSelected = true; + selectedPara.segments.push(selectedText); + + // Add unselected paragraph + const unselectedPara = createParagraph(); + const unselectedText = createText('unselected'); + unselectedPara.segments.push(unselectedText); + + model.blocks.push(selectedPara, unselectedPara); + + const mockDocument = document.implementation.createHTMLDocument('test'); + const div = mockDocument.createElement('div'); + div.textContent = 'selected'; + mockDocument.body.appendChild(div); + + const range = mockDocument.createRange(); + range.selectNodeContents(div); + + const selection: DOMSelection = { + type: 'range', + range, + isReverted: false, + }; + + trimModelForSelection(model, selection); + + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'selected', + format: {}, + isSelected: true, + }, + ], + isImplicit: true, + }, + ], + }; + + expect(model).toEqual(expectedModel); + }); +}); diff --git a/packages/roosterjs-content-model-dom/test/domUtils/style/transformColorTest.ts b/packages/roosterjs-content-model-dom/test/domUtils/style/transformColorTest.ts index d14c0952409f..a68dd6169ab0 100644 --- a/packages/roosterjs-content-model-dom/test/domUtils/style/transformColorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domUtils/style/transformColorTest.ts @@ -32,8 +32,8 @@ describe('transform to dark mode', () => { runTest( element, - '
', - '
' + '
', + '
' ); }); @@ -44,8 +44,8 @@ describe('transform to dark mode', () => { runTest( element, - '
', - '
' + '
', + '
' ); }); @@ -58,8 +58,8 @@ describe('transform to dark mode', () => { runTest( element, - '
', - '
' + '
', + '
' ); }); @@ -98,8 +98,8 @@ describe('transform to dark mode', () => { runTest( element, - '
', - '
' + '
', + '
' ); }); @@ -142,6 +142,33 @@ describe('transform to dark mode', () => { '
' ); }); + + it('Has text color on parent element and background color on child element, transform with dark color handler', () => { + const element = document.createElement('div'); + element.style.color = 'red'; + + const child1 = document.createElement('div'); + child1.style.color = 'green'; + element.appendChild(child1); + + const span1 = document.createElement('span'); + span1.style.backgroundColor = 'red'; + child1.appendChild(span1); + + const child2 = document.createElement('div'); + child2.style.color = 'yellow'; + element.appendChild(child2); + + const span2 = document.createElement('span'); + span2.style.backgroundColor = 'gray'; + child2.appendChild(span2); + + runTest( + element, + '
', + '
' + ); + }); }); describe('transform to light mode', () => { diff --git a/packages/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts b/packages/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts index 3e5f24ccc61e..a027c04e5e71 100644 --- a/packages/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts +++ b/packages/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts @@ -7,6 +7,7 @@ import { BackgroundColorFormat, DomToModelContext, ModelToDomContext, + TextColorFormat, } from 'roosterjs-content-model-types'; describe('backgroundColorFormatHandler.parse', () => { @@ -84,7 +85,7 @@ describe('backgroundColorFormatHandler.parse', () => { describe('backgroundColorFormatHandler.apply', () => { let div: HTMLElement; let context: ModelToDomContext; - let format: BackgroundColorFormat; + let format: BackgroundColorFormat & TextColorFormat; beforeEach(() => { div = document.createElement('div'); @@ -122,4 +123,23 @@ describe('backgroundColorFormatHandler.apply', () => { expectHtml(div.outerHTML, expectedResult); }); + + it('Has both text and background color in dark mode', () => { + format.backgroundColor = 'red'; + format.textColor = 'green'; + context.isDarkMode = true; + context.darkColorHandler = { + updateKnownColor: () => {}, + getDarkColor: (lightColor: string) => `var(--darkColor_${lightColor}, ${lightColor})`, + generateColorKey: defaultGenerateColorKey, + } as any; + + backgroundColorFormatHandler.apply(format, div, context); + + const expectedResult = [ + '
', + ]; + + expectHtml(div.outerHTML, expectedResult); + }); }); diff --git a/packages/roosterjs-content-model-dom/test/formatHandlers/utils/colorTest.ts b/packages/roosterjs-content-model-dom/test/formatHandlers/utils/colorTest.ts index 86ef59307b8f..745c8f52d613 100644 --- a/packages/roosterjs-content-model-dom/test/formatHandlers/utils/colorTest.ts +++ b/packages/roosterjs-content-model-dom/test/formatHandlers/utils/colorTest.ts @@ -88,10 +88,10 @@ describe('getColor without darkColorHandler', () => { const backDark = getColor(div, true, true); const textDark = getColor(div, false, true); - expect(backLight).toBe('var(--test, green)'); - expect(textLight).toBe('var(--test, red)'); - expect(backDark).toBe('var(--test, green)'); - expect(textDark).toBe('var(--test, red)'); + expect(backLight).toBe('green'); + expect(textLight).toBe('red'); + expect(backDark).toBe('green'); + expect(textDark).toBe('red'); }); it('has style color, deprecated value', () => { @@ -399,18 +399,42 @@ describe('setColor with darkColorHandler', () => { setColor(lightDiv, 'red', true, false, darkColorHandler); setColor(lightDiv, 'green', false, false, darkColorHandler); - setColor(darkDiv, 'red', true, true, darkColorHandler); + setColor(darkDiv, 'red', true, true, darkColorHandler, 'blue'); setColor(darkDiv, 'green', false, true, darkColorHandler); expect(lightDiv.outerHTML).toBe('
'); expect(darkDiv.outerHTML).toBe( - '
' + '
' ); expect(getDarkColorSpy).toHaveBeenCalledTimes(4); - expect(getDarkColorSpy).toHaveBeenCalledWith('green', undefined, 'text', lightDiv); - expect(getDarkColorSpy).toHaveBeenCalledWith('red', undefined, 'background', lightDiv); - expect(getDarkColorSpy).toHaveBeenCalledWith('green', undefined, 'text', darkDiv); - expect(getDarkColorSpy).toHaveBeenCalledWith('red', undefined, 'background', darkDiv); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'green', + undefined, + 'text', + lightDiv, + undefined + ); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'red', + undefined, + 'background', + lightDiv, + undefined + ); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'green', + undefined, + 'text', + darkDiv, + undefined + ); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'red', + undefined, + 'background', + darkDiv, + 'blue' + ); expect(updateKnownColorSpy).toHaveBeenCalledTimes(4); expect(updateKnownColorSpy).toHaveBeenCalledWith(false, '--darkColor_red', { lightModeColor: 'red', @@ -420,7 +444,7 @@ describe('setColor with darkColorHandler', () => { lightModeColor: 'green', darkModeColor: '--dark_green', }); - expect(updateKnownColorSpy).toHaveBeenCalledWith(true, '--darkColor_red', { + expect(updateKnownColorSpy).toHaveBeenCalledWith(true, '--darkColor_red_blue', { lightModeColor: 'red', darkModeColor: '--dark_red', }); @@ -445,10 +469,34 @@ describe('setColor with darkColorHandler', () => { ); expect(knownColors).toEqual({}); expect(getDarkColorSpy).toHaveBeenCalledTimes(4); - expect(getDarkColorSpy).toHaveBeenCalledWith('green', undefined, 'text', lightDiv); - expect(getDarkColorSpy).toHaveBeenCalledWith('red', undefined, 'background', lightDiv); - expect(getDarkColorSpy).toHaveBeenCalledWith('green', undefined, 'text', darkDiv); - expect(getDarkColorSpy).toHaveBeenCalledWith('red', undefined, 'background', darkDiv); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'green', + undefined, + 'text', + lightDiv, + undefined + ); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'red', + undefined, + 'background', + lightDiv, + undefined + ); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'green', + undefined, + 'text', + darkDiv, + undefined + ); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'red', + undefined, + 'background', + darkDiv, + undefined + ); expect(updateKnownColorSpy).toHaveBeenCalledTimes(4); expect(updateKnownColorSpy).toHaveBeenCalledWith(false, '--test', { lightModeColor: 'red', @@ -491,10 +539,34 @@ describe('setColor with darkColorHandler', () => { '
' ); expect(getDarkColorSpy).toHaveBeenCalledTimes(4); - expect(getDarkColorSpy).toHaveBeenCalledWith('green', undefined, 'text', lightDiv); - expect(getDarkColorSpy).toHaveBeenCalledWith('red', undefined, 'background', lightDiv); - expect(getDarkColorSpy).toHaveBeenCalledWith('green', undefined, 'text', darkDiv); - expect(getDarkColorSpy).toHaveBeenCalledWith('red', undefined, 'background', darkDiv); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'green', + undefined, + 'text', + lightDiv, + undefined + ); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'red', + undefined, + 'background', + lightDiv, + undefined + ); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'green', + undefined, + 'text', + darkDiv, + undefined + ); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'red', + undefined, + 'background', + darkDiv, + undefined + ); expect(updateKnownColorSpy).toHaveBeenCalledTimes(4); expect(updateKnownColorSpy).toHaveBeenCalledWith(false, '--darkColor_red', { lightModeColor: 'red', @@ -534,10 +606,34 @@ describe('setColor with darkColorHandler', () => { '
' ); expect(getDarkColorSpy).toHaveBeenCalledTimes(4); - expect(getDarkColorSpy).toHaveBeenCalledWith('green', undefined, 'text', lightDiv); - expect(getDarkColorSpy).toHaveBeenCalledWith('red', undefined, 'background', lightDiv); - expect(getDarkColorSpy).toHaveBeenCalledWith('green', undefined, 'text', darkDiv); - expect(getDarkColorSpy).toHaveBeenCalledWith('red', undefined, 'background', darkDiv); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'green', + undefined, + 'text', + lightDiv, + undefined + ); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'red', + undefined, + 'background', + lightDiv, + undefined + ); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'green', + undefined, + 'text', + darkDiv, + undefined + ); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'red', + undefined, + 'background', + darkDiv, + undefined + ); expect(updateKnownColorSpy).toHaveBeenCalledTimes(4); expect(updateKnownColorSpy).toHaveBeenCalledWith(false, '--red_key', { lightModeColor: 'red', @@ -808,7 +904,13 @@ describe('adaptColor', () => { it('should return color as-is in light mode with dark color handler', () => { const result = adaptColor(element, 'red', 'border', false, darkColorHandler); expect(result).toBe('red'); - expect(getDarkColorSpy).toHaveBeenCalledWith('red', undefined, 'border', element); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'red', + undefined, + 'border', + element, + undefined + ); expect(updateKnownColorSpy).toHaveBeenCalledWith(false, '--darkColor_red', { lightModeColor: 'red', darkModeColor: 'dark_red', @@ -818,7 +920,13 @@ describe('adaptColor', () => { it('should wrap color with CSS variable in dark mode', () => { const result = adaptColor(element, 'blue', 'border', true, darkColorHandler); expect(result).toBe('var(--darkColor_blue, blue)'); - expect(getDarkColorSpy).toHaveBeenCalledWith('blue', undefined, 'border', element); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'blue', + undefined, + 'border', + element, + undefined + ); expect(updateKnownColorSpy).toHaveBeenCalledWith(true, '--darkColor_blue', { lightModeColor: 'blue', darkModeColor: 'dark_red', @@ -834,7 +942,13 @@ describe('adaptColor', () => { darkColorHandler ); expect(result).toBe('green'); - expect(getDarkColorSpy).toHaveBeenCalledWith('green', undefined, 'border', element); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'green', + undefined, + 'border', + element, + undefined + ); expect(updateKnownColorSpy).toHaveBeenCalledWith(false, '--existing', { lightModeColor: 'green', darkModeColor: 'dark_red', @@ -850,7 +964,13 @@ describe('adaptColor', () => { darkColorHandler ); expect(result).toBe('var(--existing, green)'); - expect(getDarkColorSpy).toHaveBeenCalledWith('green', undefined, 'border', element); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'green', + undefined, + 'border', + element, + undefined + ); expect(updateKnownColorSpy).toHaveBeenCalledWith(true, '--existing', { lightModeColor: 'green', darkModeColor: 'dark_red', @@ -875,7 +995,13 @@ describe('adaptColor', () => { it('should generate new dark color when not in known colors', () => { const result = adaptColor(element, 'purple', 'border', true, darkColorHandler); expect(result).toBe('var(--darkColor_purple, purple)'); - expect(getDarkColorSpy).toHaveBeenCalledWith('purple', undefined, 'border', element); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'purple', + undefined, + 'border', + element, + undefined + ); expect(updateKnownColorSpy).toHaveBeenCalledWith(true, '--darkColor_purple', { lightModeColor: 'purple', darkModeColor: 'dark_red', @@ -885,7 +1011,7 @@ describe('adaptColor', () => { it('should handle text color in light mode', () => { const result = adaptColor(element, 'red', 'text', false, darkColorHandler); expect(result).toBe('red'); - expect(getDarkColorSpy).toHaveBeenCalledWith('red', undefined, 'text', element); + expect(getDarkColorSpy).toHaveBeenCalledWith('red', undefined, 'text', element, undefined); expect(updateKnownColorSpy).toHaveBeenCalledWith(false, '--darkColor_red', { lightModeColor: 'red', darkModeColor: 'dark_red', @@ -895,7 +1021,7 @@ describe('adaptColor', () => { it('should handle text color in dark mode', () => { const result = adaptColor(element, 'red', 'text', true, darkColorHandler); expect(result).toBe('var(--darkColor_red, red)'); - expect(getDarkColorSpy).toHaveBeenCalledWith('red', undefined, 'text', element); + expect(getDarkColorSpy).toHaveBeenCalledWith('red', undefined, 'text', element, undefined); expect(updateKnownColorSpy).toHaveBeenCalledWith(true, '--darkColor_red', { lightModeColor: 'red', darkModeColor: 'dark_red', @@ -905,7 +1031,13 @@ describe('adaptColor', () => { it('should handle background color in light mode', () => { const result = adaptColor(element, 'blue', 'background', false, darkColorHandler); expect(result).toBe('blue'); - expect(getDarkColorSpy).toHaveBeenCalledWith('blue', undefined, 'background', element); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'blue', + undefined, + 'background', + element, + undefined + ); expect(updateKnownColorSpy).toHaveBeenCalledWith(false, '--darkColor_blue', { lightModeColor: 'blue', darkModeColor: 'dark_red', @@ -915,7 +1047,13 @@ describe('adaptColor', () => { it('should handle background color in dark mode', () => { const result = adaptColor(element, 'blue', 'background', true, darkColorHandler); expect(result).toBe('var(--darkColor_blue, blue)'); - expect(getDarkColorSpy).toHaveBeenCalledWith('blue', undefined, 'background', element); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'blue', + undefined, + 'background', + element, + undefined + ); expect(updateKnownColorSpy).toHaveBeenCalledWith(true, '--darkColor_blue', { lightModeColor: 'blue', darkModeColor: 'dark_red', @@ -931,7 +1069,13 @@ describe('adaptColor', () => { darkColorHandler ); expect(result).toBe('black'); - expect(getDarkColorSpy).toHaveBeenCalledWith('black', undefined, 'text', element); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'black', + undefined, + 'text', + element, + undefined + ); expect(updateKnownColorSpy).toHaveBeenCalledWith(false, '--text-color', { lightModeColor: 'black', darkModeColor: 'dark_red', @@ -947,7 +1091,13 @@ describe('adaptColor', () => { darkColorHandler ); expect(result).toBe('var(--text-color, black)'); - expect(getDarkColorSpy).toHaveBeenCalledWith('black', undefined, 'text', element); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'black', + undefined, + 'text', + element, + undefined + ); expect(updateKnownColorSpy).toHaveBeenCalledWith(true, '--text-color', { lightModeColor: 'black', darkModeColor: 'dark_red', @@ -963,7 +1113,13 @@ describe('adaptColor', () => { darkColorHandler ); expect(result).toBe('white'); - expect(getDarkColorSpy).toHaveBeenCalledWith('white', undefined, 'background', element); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'white', + undefined, + 'background', + element, + undefined + ); expect(updateKnownColorSpy).toHaveBeenCalledWith(false, '--bg-color', { lightModeColor: 'white', darkModeColor: 'dark_red', @@ -979,7 +1135,13 @@ describe('adaptColor', () => { darkColorHandler ); expect(result).toBe('var(--bg-color, white)'); - expect(getDarkColorSpy).toHaveBeenCalledWith('white', undefined, 'background', element); + expect(getDarkColorSpy).toHaveBeenCalledWith( + 'white', + undefined, + 'background', + element, + undefined + ); expect(updateKnownColorSpy).toHaveBeenCalledWith(true, '--bg-color', { lightModeColor: 'white', darkModeColor: 'dark_red', 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..ed1af5dc629c 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 }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts index 33fa3d696e0d..8e449af30cc2 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts @@ -1,3 +1,5 @@ +import * as applyFormat from '../../../lib/modelToDom/utils/applyFormat'; +import * as applyMetadata from '../../../lib/modelToDom/utils/applyMetadata'; import * as reuseCachedElement from '../../../lib/domUtils/reuseCachedElement'; import { BulletListType } from '../../../lib/constants/BulletListType'; import { ContentModelListItem, ModelToDomContext } from 'roosterjs-content-model-types'; @@ -612,6 +614,96 @@ describe('handleList handles metadata', () => { }); expect(listItem.levels[0].format.listStyleType).toBe('disc'); }); + + it('List style type should be changed by metadata when there is existing UL to reuse', () => { + const listItem: ContentModelListItem = { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'UL', + format: {}, + dataset: { + editingInfo: '{"applyListStyleFromLevel":true}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }; + + context = createModelToDomContext(undefined, { + metadataAppliers: { + listLevel: listLevelMetadataApplier, + }, + }); + + const existingUL = document.createElement('ul'); + context.listFormat.nodeStack = [ + { + node: parent, + refNode: null, + }, + { + node: existingUL, + listType: 'UL', + dataset: { + editingInfo: '{"applyListStyleFromLevel":true}', + }, + format: {}, + refNode: null, + }, + ]; + + const applyFormatSpy = spyOn(applyFormat, 'applyFormat').and.callThrough(); + const applyMetadataSpy = spyOn(applyMetadata, 'applyMetadata').and.callThrough(); + + handleList(document, parent, listItem, context, null); + + expect(applyMetadataSpy).toHaveBeenCalledTimes(1); + expect(applyMetadataSpy).toHaveBeenCalledWith( + listItem.levels[0], + context.metadataAppliers.listLevel as any, + listItem.levels[0].format, + context + ); + expect(applyFormatSpy).not.toHaveBeenCalled(); + + expectHtml(parent.outerHTML, ['
']); + expect(context.listFormat).toEqual({ + threadItemCounts: [], + nodeStack: [ + { + node: parent, + refNode: null, + }, + { + node: existingUL, + listType: 'UL', + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, + format: {}, + refNode: null, + }, + ], + }); + expect(listItem.levels[0].format.listStyleType).toBe('disc'); + }); }); describe('handleList with cache', () => { diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index 6e0c3b186865..f2b1c8409eb8 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -204,10 +204,7 @@ export class EditPlugin implements EditorPlugin { case 'Delete': // Use our API to handle BACKSPACE/DELETE key. // No need to clear cache here since if we rely on browser's behavior, there will be Input event and its handler will reconcile cache - // And leave it to browser when shift key is pressed so that browser will trigger cut event - if (!event.rawEvent.shiftKey) { - keyboardDelete(editor, rawEvent, this.options); - } + keyboardDelete(editor, rawEvent, this.options); break; case 'Tab': diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts index 5a8f2a610346..c102e4e07fa5 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts @@ -2,6 +2,7 @@ import { deleteAllSegmentBefore } from './deleteSteps/deleteAllSegmentBefore'; import { deleteEmptyQuote } from './deleteSteps/deleteEmptyQuote'; import { deleteList } from './deleteSteps/deleteList'; import { deleteParagraphStyle } from './deleteSteps/deleteParagraphStyle'; +import { editTable } from 'roosterjs-content-model-api'; import { getDeleteCollapsedSelection } from './deleteSteps/deleteCollapsedSelection'; import { ChangeSource, @@ -10,6 +11,7 @@ import { isLinkUndeletable, isModifierKey, isNodeOfType, + parseTableCells, } from 'roosterjs-content-model-dom'; import { handleKeyboardEventResult, @@ -20,7 +22,12 @@ import { backwardDeleteWordSelection, forwardDeleteWordSelection, } from './deleteSteps/deleteWordSelection'; -import type { DOMSelection, DeleteSelectionStep, IEditor } from 'roosterjs-content-model-types'; +import type { + DOMSelection, + DeleteSelectionStep, + IEditor, + TableDeleteOperation, +} from 'roosterjs-content-model-types'; import type { EditOptions } from './EditOptions'; /** @@ -35,8 +42,14 @@ export function keyboardDelete(editor: IEditor, rawEvent: KeyboardEvent, options let handled = false; const selection = editor.getDOMSelection(); const { handleExpandedSelectionOnDelete } = options; + const tableDeleteType = shouldDeleteTableWithContentModel(selection, rawEvent); - if (shouldDeleteWithContentModel(selection, rawEvent, !!handleExpandedSelectionOnDelete)) { + if (tableDeleteType) { + editTable(editor, tableDeleteType); + handled = true; + } else if ( + shouldDeleteWithContentModel(selection, rawEvent, !!handleExpandedSelectionOnDelete) + ) { editor.formatContentModel( (model, context) => { const result = deleteSelection( @@ -96,8 +109,8 @@ function shouldDeleteWithContentModel( rawEvent: KeyboardEvent, handleExpandedSelection: boolean ) { - if (!selection) { - return false; // Nothing to delete + if (!selection || (rawEvent.key == 'Delete' && rawEvent.shiftKey)) { + return false; // Nothing to delete or leave it to browser when delete and shift key is pressed so that browser will trigger cut event } else if (selection.type != 'range') { return true; } else if (!selection.range.collapsed) { @@ -157,3 +170,32 @@ function canDeleteBefore(rawEvent: KeyboardEvent, text: Text, offset: number) { function canDeleteAfter(rawEvent: KeyboardEvent, text: Text, offset: number) { return rawEvent.key == 'Delete' && offset < (text.nodeValue?.length ?? 0) - 1; } + +function shouldDeleteTableWithContentModel( + selection: DOMSelection | null, + rawEvent: KeyboardEvent +): TableDeleteOperation | undefined { + if ( + selection?.type == 'table' && + (rawEvent.key == 'Backspace' || (rawEvent.key == 'Delete' && rawEvent.shiftKey)) + ) { + const { lastRow, lastColumn, table, firstColumn, firstRow } = selection; + const parsedTable = parseTableCells(table); + const rowNumber = parsedTable.length; + const isWholeColumnSelected = firstRow == 0 && lastRow == rowNumber - 1; + const columnNumber = parsedTable[lastRow].length; + const isWholeRowSelected = firstColumn == 0 && lastColumn == columnNumber - 1; + if (isWholeRowSelected && isWholeColumnSelected) { + return 'deleteTable'; + } + + if (isWholeRowSelected) { + return 'deleteRow'; + } + + if (isWholeColumnSelected) { + return 'deleteColumn'; + } + } + return undefined; +} diff --git a/packages/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts b/packages/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts index 22851c364174..a780e1beb109 100644 --- a/packages/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts +++ b/packages/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts @@ -23,6 +23,8 @@ import type { const LIST_ELEMENT_TAGS = ['UL', 'OL', 'LI']; const LIST_ELEMENT_SELECTOR = LIST_ELEMENT_TAGS.join(','); +const END_OF_PARAGRAPH = 'EOP'; +const SELECTED_CLASS = 'Selected'; interface WacContext extends DomToModelListFormat { /** @@ -77,7 +79,11 @@ const wacElementProcessor: ElementProcessor = ( return; } - if (TEMP_ELEMENTS_CLASSES.some(className => element.classList.contains(className))) { + if ( + TEMP_ELEMENTS_CLASSES.some(className => element.classList.contains(className)) || + // This is needed to remove some temporary End of paragraph elements that WAC sometimes preserves + (element.classList.contains(SELECTED_CLASS) && element.classList.contains(END_OF_PARAGRAPH)) + ) { return; } else if (shouldClearListContext(elementTag, element, context)) { const { listFormat } = context; diff --git a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index f24bb1bc3d71..b687e5969f80 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -115,7 +115,7 @@ describe('EditPlugin', () => { rawEvent, }); - expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(keyboardDeleteSpy).toHaveBeenCalled(); expect(keyboardInputSpy).not.toHaveBeenCalled(); expect(keyboardEnterSpy).not.toHaveBeenCalled(); expect(keyboardTabSpy).not.toHaveBeenCalled(); diff --git a/packages/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts b/packages/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts index eabcc95a5665..6b6cd7bf1206 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts @@ -1,5 +1,6 @@ import * as deleteCollapsedSelectionModule from '../../lib/edit/deleteSteps/deleteCollapsedSelection'; import * as deleteSelection from 'roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection'; +import * as editTableModule from 'roosterjs-content-model-api/lib/publicApi/table/editTable'; import * as handleKeyboardEventResult from '../../lib/edit/handleKeyboardEventCommon'; import { ChangeSource, setLinkUndeletable } from 'roosterjs-content-model-dom'; import { ContentModelDocument, DOMSelection, IEditor } from 'roosterjs-content-model-types'; @@ -636,6 +637,32 @@ describe('keyboardDelete', () => { expect(formatWithContentModelSpy).not.toHaveBeenCalled(); }); + it('No need to delete - Delete with shiftKey should not delete with content model', () => { + const rawEvent = { key: 'Delete', shiftKey: true } as any; + const formatWithContentModelSpy = jasmine.createSpy('formatContentModel'); + const node = document.createTextNode('test'); + const range: DOMSelection = { + type: 'range', + range: ({ + collapsed: false, + startContainer: node, + endContainer: node, + startOffset: 0, + endOffset: 4, + } as any) as Range, + isReverted: false, + }; + const editor = { + formatContentModel: formatWithContentModelSpy, + getDOMSelection: () => range, + getEnvironment: () => ({}), + } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(formatWithContentModelSpy).not.toHaveBeenCalled(); + }); + it('No need to delete - handleExpandedSelection disabled', () => { const rawEvent = { key: 'Backspace' } as any; const formatWithContentModelSpy = jasmine.createSpy('formatContentModel'); @@ -782,3 +809,651 @@ describe('keyboardDelete', () => { expect(formatWithContentModelSpy).toHaveBeenCalledTimes(1); }); }); + +describe('keyboardDelete - table selection', () => { + let editTableSpy: jasmine.Spy; + + beforeEach(() => { + editTableSpy = spyOn(editTableModule, 'editTable'); + }); + + function createMockTable(rows: number, columns: number): HTMLTableElement { + const table = document.createElement('table'); + for (let i = 0; i < rows; i++) { + const row = document.createElement('tr'); + for (let j = 0; j < columns; j++) { + const cell = document.createElement('td'); + row.appendChild(cell); + } + table.appendChild(row); + } + return table; + } + + it('Backspace on table selection with whole column selected should delete column', () => { + const table = createMockTable(3, 3); + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 0, + lastRow: 2, + firstColumn: 1, + lastColumn: 1, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Backspace' } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).toHaveBeenCalledWith(editor, 'deleteColumn'); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Backspace on table selection with whole row selected should delete row', () => { + const table = createMockTable(3, 3); + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 1, + lastRow: 1, + firstColumn: 0, + lastColumn: 2, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Backspace' } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).toHaveBeenCalledWith(editor, 'deleteRow'); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Delete with Shift on table selection with whole column selected should delete column', () => { + const table = createMockTable(3, 3); + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 0, + lastRow: 2, + firstColumn: 0, + lastColumn: 0, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Delete', shiftKey: true } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).toHaveBeenCalledWith(editor, 'deleteColumn'); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Delete with Shift on table selection with whole row selected should delete row', () => { + const table = createMockTable(3, 3); + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 2, + lastRow: 2, + firstColumn: 0, + lastColumn: 2, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Delete', shiftKey: true } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).toHaveBeenCalledWith(editor, 'deleteRow'); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Delete without Shift on table selection should not delete table rows/columns', () => { + const table = createMockTable(3, 3); + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 0, + lastRow: 2, + firstColumn: 0, + lastColumn: 0, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Delete', shiftKey: false } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + }); + + it('Backspace on table selection with partial selection should not delete table rows/columns', () => { + const table = createMockTable(3, 3); + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 0, + lastRow: 1, + firstColumn: 0, + lastColumn: 1, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Backspace' } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + }); + + it('Delete+Shift on range selection should not call formatContentModel to allow browser cut', () => { + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const node = document.createTextNode('test'); + const range: DOMSelection = { + type: 'range', + range: ({ + collapsed: false, + startContainer: node, + endContainer: node, + startOffset: 0, + endOffset: 4, + } as any) as Range, + isReverted: false, + }; + + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => range, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Delete', shiftKey: true } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Delete+Shift on collapsed range selection should not call formatContentModel to allow browser cut', () => { + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const node = document.createTextNode('test'); + const range: DOMSelection = { + type: 'range', + range: ({ + collapsed: true, + startContainer: node, + startOffset: 2, + } as any) as Range, + isReverted: false, + }; + + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => range, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Delete', shiftKey: true } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Backspace on entire table selection should delete table', () => { + const table = createMockTable(3, 3); + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 0, + lastRow: 2, + firstColumn: 0, + lastColumn: 2, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Backspace' } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).toHaveBeenCalledWith(editor, 'deleteTable'); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Shift+Delete on entire table selection should delete table', () => { + const table = createMockTable(3, 3); + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 0, + lastRow: 2, + firstColumn: 0, + lastColumn: 2, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Delete', shiftKey: true } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).toHaveBeenCalledWith(editor, 'deleteTable'); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); +}); + +describe('keyboardDelete - table selection with colspan and rowspan', () => { + let editTableSpy: jasmine.Spy; + + beforeEach(() => { + editTableSpy = spyOn(editTableModule, 'editTable'); + }); + + // Creates a table with colspan in first row: + // | col1 (colspan=2) | col3 | + // | col1 | col2 | col3 | + // | col1 | col2 | col3 | + function createTableWithColspan(): HTMLTableElement { + const table = document.createElement('table'); + + const row1 = document.createElement('tr'); + const cell1_1 = document.createElement('td'); + cell1_1.colSpan = 2; + const cell1_2 = document.createElement('td'); + row1.appendChild(cell1_1); + row1.appendChild(cell1_2); + + const row2 = document.createElement('tr'); + for (let j = 0; j < 3; j++) { + row2.appendChild(document.createElement('td')); + } + + const row3 = document.createElement('tr'); + for (let j = 0; j < 3; j++) { + row3.appendChild(document.createElement('td')); + } + + table.appendChild(row1); + table.appendChild(row2); + table.appendChild(row3); + return table; + } + + // Creates a table with rowspan in first column: + // | col1 (rowspan=2) | col2 | col3 | + // | | col2 | col3 | + // | col1 | col2 | col3 | + function createTableWithRowspan(): HTMLTableElement { + const table = document.createElement('table'); + + const row1 = document.createElement('tr'); + const cell1_1 = document.createElement('td'); + cell1_1.rowSpan = 2; + row1.appendChild(cell1_1); + row1.appendChild(document.createElement('td')); + row1.appendChild(document.createElement('td')); + + const row2 = document.createElement('tr'); + row2.appendChild(document.createElement('td')); + row2.appendChild(document.createElement('td')); + + const row3 = document.createElement('tr'); + for (let j = 0; j < 3; j++) { + row3.appendChild(document.createElement('td')); + } + + table.appendChild(row1); + table.appendChild(row2); + table.appendChild(row3); + return table; + } + + // Creates a table with both colspan and rowspan: + // | col1 (colspan=2, rowspan=2) | col3 | + // | | col3 | + // | col1 | col2 | col3 | + function createTableWithColspanAndRowspan(): HTMLTableElement { + const table = document.createElement('table'); + + const row1 = document.createElement('tr'); + const cell1_1 = document.createElement('td'); + cell1_1.colSpan = 2; + cell1_1.rowSpan = 2; + row1.appendChild(cell1_1); + row1.appendChild(document.createElement('td')); + + const row2 = document.createElement('tr'); + row2.appendChild(document.createElement('td')); + + const row3 = document.createElement('tr'); + for (let j = 0; j < 3; j++) { + row3.appendChild(document.createElement('td')); + } + + table.appendChild(row1); + table.appendChild(row2); + table.appendChild(row3); + return table; + } + + it('Backspace on table with colspan - whole row selected should delete row', () => { + const table = createTableWithColspan(); + // Table has 3 logical columns, select all columns in row 0 + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 0, + lastRow: 0, + firstColumn: 0, + lastColumn: 2, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Backspace' } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).toHaveBeenCalledWith(editor, 'deleteRow'); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Backspace on table with colspan - whole column selected should delete column', () => { + const table = createTableWithColspan(); + // Select all rows for column 2 + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 0, + lastRow: 2, + firstColumn: 2, + lastColumn: 2, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Backspace' } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).toHaveBeenCalledWith(editor, 'deleteColumn'); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Backspace on table with colspan - partial row selection should not delete row', () => { + const table = createTableWithColspan(); + // Select only 2 columns (not all 3) + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 0, + lastRow: 0, + firstColumn: 0, + lastColumn: 1, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Backspace' } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + }); + + it('Backspace on table with rowspan - whole row selected should delete row', () => { + const table = createTableWithRowspan(); + // Select all columns in row 2 (row without rowspan) + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 2, + lastRow: 2, + firstColumn: 0, + lastColumn: 2, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Backspace' } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).toHaveBeenCalledWith(editor, 'deleteRow'); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Backspace on table with rowspan - whole column selected should delete column', () => { + const table = createTableWithRowspan(); + // Select all rows for column 0 (column with rowspan) + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 0, + lastRow: 2, + firstColumn: 0, + lastColumn: 0, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Backspace' } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).toHaveBeenCalledWith(editor, 'deleteColumn'); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Backspace on table with colspan and rowspan - entire table selected should delete table', () => { + const table = createTableWithColspanAndRowspan(); + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 0, + lastRow: 2, + firstColumn: 0, + lastColumn: 2, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Backspace' } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).toHaveBeenCalledWith(editor, 'deleteTable'); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Shift+Delete on table with colspan and rowspan - entire table selected should delete table', () => { + const table = createTableWithColspanAndRowspan(); + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 0, + lastRow: 2, + firstColumn: 0, + lastColumn: 2, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Delete', shiftKey: true } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).toHaveBeenCalledWith(editor, 'deleteTable'); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Backspace on table with colspan and rowspan - whole row selected should delete row', () => { + const table = createTableWithColspanAndRowspan(); + // Select all columns in row 2 + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 2, + lastRow: 2, + firstColumn: 0, + lastColumn: 2, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Backspace' } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).toHaveBeenCalledWith(editor, 'deleteRow'); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Backspace on table with colspan and rowspan - whole column selected should delete column', () => { + const table = createTableWithColspanAndRowspan(); + // Select all rows for column 2 + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 0, + lastRow: 2, + firstColumn: 2, + lastColumn: 2, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Backspace' } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).toHaveBeenCalledWith(editor, 'deleteColumn'); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Backspace on table with colspan - partial selection should not delete', () => { + const table = createTableWithColspanAndRowspan(); + // Partial selection + const tableSelection: DOMSelection = { + type: 'table', + table, + firstRow: 0, + lastRow: 1, + firstColumn: 0, + lastColumn: 1, + }; + + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: () => tableSelection, + getEnvironment: () => ({}), + } as any; + + const rawEvent = { key: 'Backspace' } as any; + + keyboardDelete(editor, rawEvent, {}); + + expect(editTableSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWacTest.ts b/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWacTest.ts index 9174c55d92e5..6215c4db93c5 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWacTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWacTest.ts @@ -6112,4 +6112,69 @@ describe('wordOnlineHandler', () => { true ); }); + + it('Should skip elements with both Selected and EOP classes', () => { + runTest( + '
HelloWorld
', + '
HelloWorld
', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'Text', text: 'Hello', format: {} }, + { segmentType: 'Text', text: 'World', format: {} }, + ], + format: {}, + }, + ], + }, + true + ); + }); + + it('Should not skip elements with only Selected class', () => { + runTest( + '
Hello!World
', + '
Hello!World
', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'Text', text: 'Hello', format: {} }, + { segmentType: 'Text', text: '!', format: {} }, + { segmentType: 'Text', text: 'World', format: {} }, + ], + format: {}, + }, + ], + }, + true + ); + }); + + it('Should not skip elements with only EOP class', () => { + runTest( + '
Hello!World
', + '
Hello!World
', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'Text', text: 'Hello', format: {} }, + { segmentType: 'Text', text: '!', format: {} }, + { segmentType: 'Text', text: 'World', format: {} }, + ], + format: {}, + }, + ], + }, + true + ); + }); }); diff --git a/packages/roosterjs-content-model-types/lib/context/DarkColorHandler.ts b/packages/roosterjs-content-model-types/lib/context/DarkColorHandler.ts index f9f459cd9922..270f2ac70835 100644 --- a/packages/roosterjs-content-model-types/lib/context/DarkColorHandler.ts +++ b/packages/roosterjs-content-model-types/lib/context/DarkColorHandler.ts @@ -20,12 +20,14 @@ export interface Colors { * @param baseLValue Base value of light used for dark value calculation * @param colorType @optional Type of color, can be text, background, or border * @param element @optional Source HTML element of the color + * @param comparingColor @optional When generating dark color for background color, we can provide text color as comparingColor to make sure the generated dark border color has enough contrast with text color in dark mode */ export type ColorTransformFunction = ( lightColor: string, baseLValue?: number, colorType?: 'text' | 'background' | 'border', - element?: HTMLElement + element?: HTMLElement, + comparingColor?: string ) => string; /** diff --git a/packages/roosterjs-content-model-types/lib/enum/TableOperation.ts b/packages/roosterjs-content-model-types/lib/enum/TableOperation.ts index 16ff0c84aec3..85b3d49567b2 100644 --- a/packages/roosterjs-content-model-types/lib/enum/TableOperation.ts +++ b/packages/roosterjs-content-model-types/lib/enum/TableOperation.ts @@ -153,6 +153,20 @@ export type TableCellVerticalAlignOperation = */ | 'alignCellBottom'; +/** + * Operations used by editTable() API to shift table cell content up or left + */ +export type TableCellShiftOperation = + /** + * Move the table cell content to the cell on the left + */ + | 'shiftCellsLeft' + + /** + * Move the table cell content to the cell above + */ + | 'shiftCellsUp'; + /** * Operations used by editTable() API */ @@ -166,4 +180,5 @@ export type TableOperation = | TableSplitOperation | TableAlignOperation | TableCellHorizontalAlignOperation - | TableCellVerticalAlignOperation; + | TableCellVerticalAlignOperation + | TableCellShiftOperation; diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 53a4bd7fb34b..b7951fba7c9c 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -96,6 +96,7 @@ export { TableAlignOperation, TableCellHorizontalAlignOperation, TableCellVerticalAlignOperation, + TableCellShiftOperation, } from './enum/TableOperation'; export { PasteType } from './enum/PasteType'; export { BorderOperations } from './enum/BorderOperations'; diff --git a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts index 5c749c40634a..91b64831958a 100644 --- a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts @@ -1062,7 +1062,11 @@ export class EditorAdapter extends Editor implements ILegacyEditor { node, true /*includeSelf*/, direction == ColorTransformDirection.DarkToLight ? 'darkToLight' : 'lightToDark', - core.darkColorHandler + core.darkColorHandler, + { + tableBorders: this.isExperimentalFeatureEnabled('TransformTableBorderColors'), + }, + core.format.defaultFormat.textColor ); } } diff --git a/tools/karma.test.js b/tools/karma.test.js index 22a93d2442f9..7e5be9f2ad81 100644 --- a/tools/karma.test.js +++ b/tools/karma.test.js @@ -1,14 +1,24 @@ module.exports = function (contexts) { - if (!!__karma__.config.components) { - const filenameWithoutTest = __karma__.config.components.replace('Test', ''); - const filterRegExpByFilename = new RegExp(filenameWithoutTest); + const components = __karma__.config.components; + const testPathPattern = __karma__.config.testPathPattern; + + if (!!components || !!testPathPattern) { + const pattern = testPathPattern || components.replace('Test', ''); + const filterRegExpByFilename = new RegExp(pattern); const specificFiles = []; contexts.forEach(context => { specificFiles.push(...context.keys().filter(path => filterRegExpByFilename.test(path))); }); - return specificFiles.map(context); + console.log( + '\n\nRunning test cases from ' + + specificFiles.length + + ' files matching pattern: ' + + pattern + ); + + return specificFiles.map(file => contexts[0](file)); } else { const specificFiles = []; diff --git a/versions.json b/versions.json index 6aa6a4fe9a62..529074ebed27 100644 --- a/versions.json +++ b/versions.json @@ -1,6 +1,6 @@ { "react": "9.0.4", - "main": "9.45.2", - "legacyAdapter": "8.65.2", + "main": "9.46.0", + "legacyAdapter": "8.65.3", "overrides": {} } diff --git a/yarn.lock b/yarn.lock index 6f76c129dbab..97adedf09839 100644 --- a/yarn.lock +++ b/yarn.lock @@ -550,7 +550,7 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" -"@jridgewell/trace-mapping@^0.3.20", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": version "0.3.25" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== @@ -663,10 +663,26 @@ dependencies: "@types/trusted-types" "*" -"@types/estree@^1.0.5": - version "1.0.5" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" - integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== +"@types/eslint-scope@^3.7.7": + version "3.7.7" + resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" + integrity sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg== + dependencies: + "@types/eslint" "*" + "@types/estree" "*" + +"@types/eslint@*": + version "9.6.1" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-9.6.1.tgz#d5795ad732ce81715f27f75da913004a56751584" + integrity sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag== + dependencies: + "@types/estree" "*" + "@types/json-schema" "*" + +"@types/estree@*", "@types/estree@^1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== "@types/events@*": version "3.0.0" @@ -687,22 +703,7 @@ resolved "https://registry.yarnpkg.com/@types/jasmine/-/jasmine-3.5.10.tgz#a1a41012012b5da9d4b205ba9eba58f6cce2ab7b" integrity sha512-3F8qpwBAiVc5+HPJeXJpbrl+XjawGmciN5LgiO7Gv1pl1RHtjoMNqZpqEksaPJW05ViKe8snYInRs6xB25Xdew== -"@types/json-schema@^7.0.12": - version "7.0.13" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.13.tgz#02c24f4363176d2d18fc8b70b9f3c54aba178a85" - integrity sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ== - -"@types/json-schema@^7.0.4": - version "7.0.5" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.5.tgz#dcce4430e64b443ba8945f0290fb564ad5bac6dd" - integrity sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ== - -"@types/json-schema@^7.0.8": - version "7.0.12" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.12.tgz#d70faba7039d5fca54c83c7dbab41051d2b6f6cb" - integrity sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA== - -"@types/json-schema@^7.0.9": +"@types/json-schema@*", "@types/json-schema@^7.0.12", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -936,125 +937,125 @@ "@typescript-eslint/types" "6.7.3" eslint-visitor-keys "^3.4.1" -"@webassemblyjs/ast@1.12.1", "@webassemblyjs/ast@^1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.12.1.tgz#bb16a0e8b1914f979f45864c23819cc3e3f0d4bb" - integrity sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg== +"@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.14.1.tgz#a9f6a07f2b03c95c8d38c4536a1fdfb521ff55b6" + integrity sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ== dependencies: - "@webassemblyjs/helper-numbers" "1.11.6" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/helper-numbers" "1.13.2" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" -"@webassemblyjs/floating-point-hex-parser@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz#dacbcb95aff135c8260f77fa3b4c5fea600a6431" - integrity sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw== +"@webassemblyjs/floating-point-hex-parser@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz#fcca1eeddb1cc4e7b6eed4fc7956d6813b21b9fb" + integrity sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA== -"@webassemblyjs/helper-api-error@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz#6132f68c4acd59dcd141c44b18cbebbd9f2fa768" - integrity sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q== +"@webassemblyjs/helper-api-error@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz#e0a16152248bc38daee76dd7e21f15c5ef3ab1e7" + integrity sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ== -"@webassemblyjs/helper-buffer@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz#6df20d272ea5439bf20ab3492b7fb70e9bfcb3f6" - integrity sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw== +"@webassemblyjs/helper-buffer@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz#822a9bc603166531f7d5df84e67b5bf99b72b96b" + integrity sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA== -"@webassemblyjs/helper-numbers@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz#cbce5e7e0c1bd32cf4905ae444ef64cea919f1b5" - integrity sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g== +"@webassemblyjs/helper-numbers@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz#dbd932548e7119f4b8a7877fd5a8d20e63490b2d" + integrity sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA== dependencies: - "@webassemblyjs/floating-point-hex-parser" "1.11.6" - "@webassemblyjs/helper-api-error" "1.11.6" + "@webassemblyjs/floating-point-hex-parser" "1.13.2" + "@webassemblyjs/helper-api-error" "1.13.2" "@xtuc/long" "4.2.2" -"@webassemblyjs/helper-wasm-bytecode@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz#bb2ebdb3b83aa26d9baad4c46d4315283acd51e9" - integrity sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA== +"@webassemblyjs/helper-wasm-bytecode@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz#e556108758f448aae84c850e593ce18a0eb31e0b" + integrity sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA== -"@webassemblyjs/helper-wasm-section@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz#3da623233ae1a60409b509a52ade9bc22a37f7bf" - integrity sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g== +"@webassemblyjs/helper-wasm-section@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz#9629dda9c4430eab54b591053d6dc6f3ba050348" + integrity sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw== dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-buffer" "1.12.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/wasm-gen" "1.12.1" + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/wasm-gen" "1.14.1" -"@webassemblyjs/ieee754@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz#bb665c91d0b14fffceb0e38298c329af043c6e3a" - integrity sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg== +"@webassemblyjs/ieee754@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz#1c5eaace1d606ada2c7fd7045ea9356c59ee0dba" + integrity sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw== dependencies: "@xtuc/ieee754" "^1.2.0" -"@webassemblyjs/leb128@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.6.tgz#70e60e5e82f9ac81118bc25381a0b283893240d7" - integrity sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ== +"@webassemblyjs/leb128@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.13.2.tgz#57c5c3deb0105d02ce25fa3fd74f4ebc9fd0bbb0" + integrity sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw== dependencies: "@xtuc/long" "4.2.2" -"@webassemblyjs/utf8@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.6.tgz#90f8bc34c561595fe156603be7253cdbcd0fab5a" - integrity sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA== - -"@webassemblyjs/wasm-edit@^1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz#9f9f3ff52a14c980939be0ef9d5df9ebc678ae3b" - integrity sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-buffer" "1.12.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/helper-wasm-section" "1.12.1" - "@webassemblyjs/wasm-gen" "1.12.1" - "@webassemblyjs/wasm-opt" "1.12.1" - "@webassemblyjs/wasm-parser" "1.12.1" - "@webassemblyjs/wast-printer" "1.12.1" - -"@webassemblyjs/wasm-gen@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz#a6520601da1b5700448273666a71ad0a45d78547" - integrity sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/ieee754" "1.11.6" - "@webassemblyjs/leb128" "1.11.6" - "@webassemblyjs/utf8" "1.11.6" - -"@webassemblyjs/wasm-opt@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz#9e6e81475dfcfb62dab574ac2dda38226c232bc5" - integrity sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-buffer" "1.12.1" - "@webassemblyjs/wasm-gen" "1.12.1" - "@webassemblyjs/wasm-parser" "1.12.1" - -"@webassemblyjs/wasm-parser@1.12.1", "@webassemblyjs/wasm-parser@^1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz#c47acb90e6f083391e3fa61d113650eea1e95937" - integrity sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-api-error" "1.11.6" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/ieee754" "1.11.6" - "@webassemblyjs/leb128" "1.11.6" - "@webassemblyjs/utf8" "1.11.6" - -"@webassemblyjs/wast-printer@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz#bcecf661d7d1abdaf989d8341a4833e33e2b31ac" - integrity sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA== - dependencies: - "@webassemblyjs/ast" "1.12.1" +"@webassemblyjs/utf8@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.13.2.tgz#917a20e93f71ad5602966c2d685ae0c6c21f60f1" + integrity sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ== + +"@webassemblyjs/wasm-edit@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz#ac6689f502219b59198ddec42dcd496b1004d597" + integrity sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/helper-wasm-section" "1.14.1" + "@webassemblyjs/wasm-gen" "1.14.1" + "@webassemblyjs/wasm-opt" "1.14.1" + "@webassemblyjs/wasm-parser" "1.14.1" + "@webassemblyjs/wast-printer" "1.14.1" + +"@webassemblyjs/wasm-gen@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz#991e7f0c090cb0bb62bbac882076e3d219da9570" + integrity sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/ieee754" "1.13.2" + "@webassemblyjs/leb128" "1.13.2" + "@webassemblyjs/utf8" "1.13.2" + +"@webassemblyjs/wasm-opt@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz#e6f71ed7ccae46781c206017d3c14c50efa8106b" + integrity sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/wasm-gen" "1.14.1" + "@webassemblyjs/wasm-parser" "1.14.1" + +"@webassemblyjs/wasm-parser@1.14.1", "@webassemblyjs/wasm-parser@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz#b3e13f1893605ca78b52c68e54cf6a865f90b9fb" + integrity sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-api-error" "1.13.2" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/ieee754" "1.13.2" + "@webassemblyjs/leb128" "1.13.2" + "@webassemblyjs/utf8" "1.13.2" + +"@webassemblyjs/wast-printer@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz#3bb3e9638a8ae5fdaf9610e7a06b4d9f9aa6fe07" + integrity sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw== + dependencies: + "@webassemblyjs/ast" "1.14.1" "@xtuc/long" "4.2.2" "@xtuc/ieee754@^1.2.0": @@ -1075,20 +1076,20 @@ accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8: mime-types "~2.1.34" negotiator "0.6.3" -acorn-import-attributes@^1.9.5: - version "1.9.5" - resolved "https://registry.yarnpkg.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz#7eb1557b1ba05ef18b5ed0ec67591bfab04688ef" - integrity sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ== +acorn-import-phases@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz#16eb850ba99a056cb7cbfe872ffb8972e18c8bd7" + integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ== acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.7.1, acorn@^8.8.2, acorn@^8.9.0: - version "8.12.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248" - integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== +acorn@^8.15.0, acorn@^8.9.0: + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== address@^1.0.1: version "1.1.2" @@ -1100,6 +1101,13 @@ ajv-errors@^1.0.0: resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d" integrity sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ== +ajv-formats@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" + integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== + dependencies: + ajv "^8.0.0" + ajv-keywords@^3.1.0: version "3.4.0" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.4.0.tgz#4b831e7b531415a7cc518cd404e73f6193c6349d" @@ -1110,12 +1118,14 @@ ajv-keywords@^3.4.1: resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.4.1.tgz#ef916e271c64ac12171fd8384eaae6b2345854da" integrity sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ== -ajv-keywords@^3.5.2: - version "3.5.2" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" - integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== +ajv-keywords@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-5.1.0.tgz#69d4d385a4733cdbeab44964a1170a88f87f0e16" + integrity sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw== + dependencies: + fast-deep-equal "^3.1.3" -ajv@^6.1.0, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.12.5, ajv@^6.5.5: +ajv@^6.1.0, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.5.5: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -1125,6 +1135,16 @@ ajv@^6.1.0, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.12.5, ajv@^6.5.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^8.0.0, ajv@^8.9.0: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" + integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + ansi-colors@^3.0.0: version "3.2.4" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.4.tgz#e3a3da4bfbae6c86a9c285625de124a234026fbf" @@ -1423,6 +1443,11 @@ base@^0.11.1: mixin-deep "^1.2.0" pascalcase "^0.1.1" +baseline-browser-mapping@^2.9.0: + version "2.9.19" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz#3e508c43c46d961eb4d7d2e5b8d1dd0f9ee4f488" + integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg== + batch@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" @@ -1523,15 +1548,16 @@ braces@^3.0.1, braces@^3.0.2, braces@~3.0.2: dependencies: fill-range "^7.0.1" -browserslist@^4.21.10: - version "4.23.3" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.3.tgz#debb029d3c93ebc97ffbc8d9cbb03403e227c800" - integrity sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA== +browserslist@^4.28.1: + version "4.28.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95" + integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== dependencies: - caniuse-lite "^1.0.30001646" - electron-to-chromium "^1.5.4" - node-releases "^2.0.18" - update-browserslist-db "^1.1.0" + baseline-browser-mapping "^2.9.0" + caniuse-lite "^1.0.30001759" + electron-to-chromium "^1.5.263" + node-releases "^2.0.27" + update-browserslist-db "^1.2.0" buffer-from@^1.0.0: version "1.1.2" @@ -1602,10 +1628,10 @@ camelcase@^5.0.0, camelcase@^5.3.1: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -caniuse-lite@^1.0.30001646: - version "1.0.30001655" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz#0ce881f5a19a2dcfda2ecd927df4d5c1684b982f" - integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg== +caniuse-lite@^1.0.30001759: + version "1.0.30001769" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz#1ad91594fad7dc233777c2781879ab5409f7d9c2" + integrity sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg== caseless@~0.12.0: version "0.12.0" @@ -2284,10 +2310,10 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== -electron-to-chromium@^1.5.4: - version "1.5.13" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.13.tgz#1abf0410c5344b2b829b7247e031f02810d442e6" - integrity sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q== +electron-to-chromium@^1.5.263: + version "1.5.286" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz#142be1ab5e1cd5044954db0e5898f60a4960384e" + integrity sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A== emoji-regex@^7.0.1: version "7.0.3" @@ -2356,13 +2382,13 @@ enhanced-resolve@4.1.0: memory-fs "^0.4.0" tapable "^1.0.0" -enhanced-resolve@^5.0.0, enhanced-resolve@^5.17.1: - version "5.17.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz#67bfbbcc2f81d511be77d686a90267ef7f898a15" - integrity sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg== +enhanced-resolve@^5.0.0, enhanced-resolve@^5.17.4: + version "5.19.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz#6687446a15e969eaa63c2fa2694510e17ae6d97c" + integrity sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg== dependencies: graceful-fs "^4.2.4" - tapable "^2.2.0" + tapable "^2.3.0" ent@~2.2.0: version "2.2.0" @@ -2460,10 +2486,10 @@ es-iterator-helpers@^1.0.12: iterator.prototype "^1.1.2" safe-array-concat "^1.0.1" -es-module-lexer@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.2.1.tgz#ba303831f63e6a394983fde2f97ad77b22324527" - integrity sha512-9978wrXM50Y4rTMmW5kXIC09ZdXQZqkE4mxhwkd8VbzsGkXGPgV4zWuqQJgCEzYngdo2dYDa0l8xhX4fkSwJSg== +es-module-lexer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.0.0.tgz#f657cd7a9448dcdda9c070a3cb75e5dc1e85f5b1" + integrity sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw== es-set-tostringtag@^2.0.1: version "2.0.1" @@ -2500,7 +2526,7 @@ escalade@^3.1.1: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== -escalade@^3.1.2: +escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== @@ -2906,6 +2932,11 @@ fast-levenshtein@^2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-uri@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa" + integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA== + fastq@^1.6.0: version "1.15.0" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a" @@ -4280,6 +4311,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + json-schema@0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" @@ -4521,10 +4557,10 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= -loader-runner@^4.2.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" - integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== +loader-runner@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.1.tgz#6c76ed29b0ccce9af379208299f07f876de737e3" + integrity sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q== loader-utils@1.2.3, loader-utils@^1.2.3: version "1.2.3" @@ -4581,9 +4617,9 @@ lodash.merge@^4.6.2: integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== lodash@^4.0.1, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + version "4.17.23" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" + integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== log4js@^6.4.1: version "6.4.1" @@ -4934,10 +4970,10 @@ node-forge@0.9.0: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579" integrity sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ== -node-releases@^2.0.18: - version "2.0.18" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.18.tgz#f010e8d35e2fe8d6b2944f03f70213ecedc4ca3f" - integrity sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g== +node-releases@^2.0.27: + version "2.0.27" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" + integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== normalize-path@^2.1.1: version "2.1.1" @@ -5352,10 +5388,10 @@ picocolors@^0.2.1: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-0.2.1.tgz#570670f793646851d1ba135996962abad587859f" integrity sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA== -picocolors@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.1.tgz#a8ad579b571952f0e5d25892de5445bcfe25aaa1" - integrity sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew== +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== picomatch@^2.0.4: version "2.2.1" @@ -5765,6 +5801,11 @@ require-directory@^2.1.1: resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + require-main-filename@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" @@ -5973,14 +6014,15 @@ schema-utils@^2.6.1, schema-utils@^2.6.5, schema-utils@^2.6.6, schema-utils@^2.7 ajv "^6.12.2" ajv-keywords "^3.4.1" -schema-utils@^3.1.1, schema-utils@^3.2.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe" - integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg== +schema-utils@^4.3.0, schema-utils@^4.3.3: + version "4.3.3" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.3.3.tgz#5b1850912fa31df90716963d45d9121fdfc09f46" + integrity sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA== dependencies: - "@types/json-schema" "^7.0.8" - ajv "^6.12.5" - ajv-keywords "^3.5.2" + "@types/json-schema" "^7.0.9" + ajv "^8.9.0" + ajv-formats "^2.1.1" + ajv-keywords "^5.1.0" select-hose@^2.0.0: version "2.0.0" @@ -6040,7 +6082,7 @@ send@0.19.0: range-parser "~1.2.1" statuses "2.0.1" -serialize-javascript@^6.0.1: +serialize-javascript@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== @@ -6585,29 +6627,29 @@ tapable@^1.0.0: resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== -tapable@^2.1.1, tapable@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" - integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== +tapable@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.0.tgz#7e3ea6d5ca31ba8e078b560f0d83ce9a14aa8be6" + integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg== -terser-webpack-plugin@^5.3.10: - version "5.3.10" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz#904f4c9193c6fd2a03f693a2150c62a92f40d199" - integrity sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w== +terser-webpack-plugin@^5.3.16: + version "5.3.16" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz#741e448cc3f93d8026ebe4f7ef9e4afacfd56330" + integrity sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q== dependencies: - "@jridgewell/trace-mapping" "^0.3.20" + "@jridgewell/trace-mapping" "^0.3.25" jest-worker "^27.4.5" - schema-utils "^3.1.1" - serialize-javascript "^6.0.1" - terser "^5.26.0" + schema-utils "^4.3.0" + serialize-javascript "^6.0.2" + terser "^5.31.1" -terser@^5.26.0: - version "5.31.6" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.31.6.tgz#c63858a0f0703988d0266a82fcbf2d7ba76422b1" - integrity sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg== +terser@^5.31.1: + version "5.46.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.46.0.tgz#1b81e560d584bbdd74a8ede87b4d9477b0ff9695" + integrity sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg== dependencies: "@jridgewell/source-map" "^0.3.3" - acorn "^8.8.2" + acorn "^8.15.0" commander "^2.20.0" source-map-support "~0.5.20" @@ -6979,13 +7021,13 @@ upath@^1.1.1: resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.2.tgz#3db658600edaeeccbe6db5e684d67ee8c2acd068" integrity sha512-kXpym8nmDmlCBr7nKdIx8P2jNBa+pBpIUFRnKJ4dr8htyYGJFokkr2ZvERRtUN+9SY+JqXouNgUPtv6JQva/2Q== -update-browserslist-db@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz#7ca61c0d8650766090728046e416a8cde682859e" - integrity sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ== +update-browserslist-db@^1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" + integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== dependencies: - escalade "^3.1.2" - picocolors "^1.0.1" + escalade "^3.2.0" + picocolors "^1.1.1" uri-js@^4.2.2: version "4.4.1" @@ -7073,10 +7115,10 @@ vscode-textmate@5.2.0: resolved "https://registry.yarnpkg.com/vscode-textmate/-/vscode-textmate-5.2.0.tgz#01f01760a391e8222fe4f33fbccbd1ad71aed74e" integrity sha512-Uw5ooOQxRASHgu6C7GVvUxisKXfSgW4oFlO+aa+PAkgmH89O3CXxEEzNRNtHSqtXFTl0nAC1uYj0GMSH27uwtQ== -watchpack@^2.4.1: - version "2.4.2" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.2.tgz#2feeaed67412e7c33184e5a79ca738fbd38564da" - integrity sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw== +watchpack@^2.4.4: + version "2.5.1" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.5.1.tgz#dd38b601f669e0cbf567cb802e75cead82cde102" + integrity sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg== dependencies: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" @@ -7170,39 +7212,41 @@ webpack-merge@^4.1.5: dependencies: lodash "^4.17.15" -webpack-sources@^3.2.3: - version "3.2.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" - integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== - -webpack@5.94.0: - version "5.94.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.94.0.tgz#77a6089c716e7ab90c1c67574a28da518a20970f" - integrity sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg== - dependencies: - "@types/estree" "^1.0.5" - "@webassemblyjs/ast" "^1.12.1" - "@webassemblyjs/wasm-edit" "^1.12.1" - "@webassemblyjs/wasm-parser" "^1.12.1" - acorn "^8.7.1" - acorn-import-attributes "^1.9.5" - browserslist "^4.21.10" +webpack-sources@^3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.3.tgz#d4bf7f9909675d7a070ff14d0ef2a4f3c982c723" + integrity sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg== + +webpack@5.104.1: + version "5.104.1" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.104.1.tgz#94bd41eb5dbf06e93be165ba8be41b8260d4fb1a" + integrity sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA== + dependencies: + "@types/eslint-scope" "^3.7.7" + "@types/estree" "^1.0.8" + "@types/json-schema" "^7.0.15" + "@webassemblyjs/ast" "^1.14.1" + "@webassemblyjs/wasm-edit" "^1.14.1" + "@webassemblyjs/wasm-parser" "^1.14.1" + acorn "^8.15.0" + acorn-import-phases "^1.0.3" + browserslist "^4.28.1" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.17.1" - es-module-lexer "^1.2.1" + enhanced-resolve "^5.17.4" + es-module-lexer "^2.0.0" eslint-scope "5.1.1" events "^3.2.0" glob-to-regexp "^0.4.1" graceful-fs "^4.2.11" json-parse-even-better-errors "^2.3.1" - loader-runner "^4.2.0" + loader-runner "^4.3.1" mime-types "^2.1.27" neo-async "^2.6.2" - schema-utils "^3.2.0" - tapable "^2.1.1" - terser-webpack-plugin "^5.3.10" - watchpack "^2.4.1" - webpack-sources "^3.2.3" + schema-utils "^4.3.3" + tapable "^2.3.0" + terser-webpack-plugin "^5.3.16" + watchpack "^2.4.4" + webpack-sources "^3.3.3" websocket-driver@>=0.5.1: version "0.7.3"