From 2ba24417a1f4eccc71ee9e193b73c683f06b69cf Mon Sep 17 00:00:00 2001 From: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:34:51 -0300 Subject: [PATCH 01/18] Fix publish file (#3267) * fix publish file * remove file --- tools/buildTools/publish.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/buildTools/publish.js b/tools/buildTools/publish.js index 39fd6133e75..ef3c2f34302 100644 --- a/tools/buildTools/publish.js +++ b/tools/buildTools/publish.js @@ -1,13 +1,14 @@ 'use strict'; const path = require('path'); +const fs = require('fs'); const exec = require('child_process').execSync; const { distPath, readPackageJson, packages } = require('./common'); const VersionRegex = /\d+\.\d+\.\d+(-([^\.]+)(\.\d+)?)?/; const NpmrcContent = 'registry=https://registry.npmjs.com/\n//registry.npmjs.com/:_authToken='; -function publish() { +function publish(options) { packages.forEach(packageName => { const json = readPackageJson(packageName, false /*readFromSourceFolder*/); const localVersion = json.version; From 0c78d305c4be245965798514bdc700b3a681c165 Mon Sep 17 00:00:00 2001 From: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:55:31 -0300 Subject: [PATCH 02/18] [Table Improvements] Add undoSnapshot when tab on a table cell (#3265) Add undoSnapshot after pressing Tab key in a table that has new content, otherwise if the user type content in a table and press tab to move to another cell and then undo the content, all the typed content will be removed. --- .../corePlugin/selection/SelectionPlugin.ts | 3 + .../selection/SelectionPluginTest.ts | 131 ++++++++++++++++++ 2 files changed, 134 insertions(+) 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 90b37d6af95..92281709043 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/test/corePlugin/selection/SelectionPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts index e565ddf5aa6..53f37e76229 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', From df8af64789ca9db154045e38969b6d38e671be5c Mon Sep 17 00:00:00 2001 From: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:52:00 -0300 Subject: [PATCH 03/18] [Table Improvements] Use keyboard to delete rows and columns (#3270) When press backspace or shift + delete when an entire row or column, delete the column and row. --- .../lib/edit/EditPlugin.ts | 5 +- .../lib/edit/keyboardDelete.ts | 50 +- .../test/edit/EditPluginTest.ts | 2 +- .../test/edit/keyboardDeleteTest.ts | 675 ++++++++++++++++++ 4 files changed, 723 insertions(+), 9 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index 6e0c3b18686..f2b1c8409eb 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 5a8f2a61034..c102e4e07fa 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/test/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index f24bb1bc3d7..b687e5969f8 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 eabcc95a566..6b6cd7bf120 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); + }); +}); From 5794d0af45fec66bde346ace328800625c0f1990 Mon Sep 17 00:00:00 2001 From: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:54:59 -0300 Subject: [PATCH 04/18] [Table Improvements] Add Shift Cells Table Operation (#3271) Add new shift cells up and shift cells left table operations. These operations move the table cell content to the cells at left or above. --- .../demoButtons/tableEditButtons.ts | 11 +- .../lib/modelApi/table/shiftCells.ts | 49 ++ .../lib/publicApi/table/editTable.ts | 6 + .../test/modelApi/table/shiftCellsTest.ts | 443 ++++++++++++++++++ .../test/publicApi/table/editTableTest.ts | 17 + .../lib/enum/TableOperation.ts | 17 +- .../lib/index.ts | 1 + 7 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 packages/roosterjs-content-model-api/lib/modelApi/table/shiftCells.ts create mode 100644 packages/roosterjs-content-model-api/test/modelApi/table/shiftCellsTest.ts diff --git a/demo/scripts/controlsV2/demoButtons/tableEditButtons.ts b/demo/scripts/controlsV2/demoButtons/tableEditButtons.ts index 1a374b2f7e5..764a04d0490 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/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 00000000000..342b9ede41f --- /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/publicApi/table/editTable.ts b/packages/roosterjs-content-model-api/lib/publicApi/table/editTable.ts index 21c1db1753d..5d1c55c70c3 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/test/modelApi/table/shiftCellsTest.ts b/packages/roosterjs-content-model-api/test/modelApi/table/shiftCellsTest.ts new file mode 100644 index 00000000000..e7ec1a2b3ad --- /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/publicApi/table/editTableTest.ts b/packages/roosterjs-content-model-api/test/publicApi/table/editTableTest.ts index 7e5fe3090dc..526e57d0a1a 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-types/lib/enum/TableOperation.ts b/packages/roosterjs-content-model-types/lib/enum/TableOperation.ts index 16ff0c84aec..85b3d49567b 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 53a4bd7fb34..b7951fba7c9 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'; From abc43d1a8acaaf6dd51d99841761fe20a7e56f95 Mon Sep 17 00:00:00 2001 From: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:50:48 -0300 Subject: [PATCH 05/18] align table cell list (#3275) When apply alignment in table cells that has list items, also apply the alignment to the list items. --- .../lib/modelApi/table/alignTableCell.ts | 16 +-- .../test/modelApi/table/alignTableCellTest.ts | 109 +++++++++++++++++- 2 files changed, 117 insertions(+), 8 deletions(-) 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 28b03d5e13d..d0faf53a882 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/test/modelApi/table/alignTableCellTest.ts b/packages/roosterjs-content-model-api/test/modelApi/table/alignTableCellTest.ts index a943b4320f1..046287e4bbf 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', () => { From 864f6ff950613fb9611266f17e21b708f0c92af9 Mon Sep 17 00:00:00 2001 From: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:53:56 -0300 Subject: [PATCH 06/18] fill gaps (#3272) --- .../domToModel/processors/tableProcessor.ts | 25 +- .../processors/tableProcessorTest.ts | 455 ++++++++++++++++++ 2 files changed, 478 insertions(+), 2 deletions(-) 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 5fefd5e831d..3a978f5b3c3 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-dom/test/domToModel/processors/tableProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts index 9b11ba09ebc..94b07444b23 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) From 8cc4cbfb1ce84565ba6c64d7efab0161d6a983bf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:40:57 -0800 Subject: [PATCH 07/18] Bump lodash from 4.17.21 to 4.17.23 (#3266) Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23. - [Release notes](https://github.com/lodash/lodash/releases) - [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23) --- updated-dependencies: - dependency-name: lodash dependency-version: 4.17.23 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jiuqing Song --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6f76c129dba..fca09aa8980 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4581,9 +4581,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" From 01f1d23cc27c567ae018840047aa0af340c45c68 Mon Sep 17 00:00:00 2001 From: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:28:15 -0300 Subject: [PATCH 08/18] fix table format (#3277) When triggering clearFormat on table cells, do not clear the cell or the table format. --- .../lib/modelApi/common/clearModelFormat.ts | 21 +- .../modelApi/common/clearModelFormatTest.ts | 360 +++++++++++++++++- 2 files changed, 372 insertions(+), 9 deletions(-) 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 5e1a551f6d0..c84202f3e64 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/test/modelApi/common/clearModelFormatTest.ts b/packages/roosterjs-content-model-api/test/modelApi/common/clearModelFormatTest.ts index 932cb19fe05..2da502627a2 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(); + }); }); From e141792c09e4f113261afdcf53e06495d42cd3db Mon Sep 17 00:00:00 2001 From: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:37:36 -0300 Subject: [PATCH 09/18] [Table Improvements] Add preview for table cell selection (#3274) When start shadow edit, check if table cells are selected, if they are selected, remove the background color to make the styles changes visible in the table. --- .../setDOMSelection/setDOMSelection.ts | 107 +----- .../setDOMSelection/setTableCellsStyle.ts | 120 +++++++ .../setDOMSelection/toggleTableSelection.ts | 33 ++ .../switchShadowEdit/switchShadowEdit.ts | 3 + .../setDOMSelection/setTableCellsStyleTest.ts | 338 ++++++++++++++++++ .../toggleTableSelectionTest.ts | 230 ++++++++++++ .../switchShadowEdit/switchShadowEditTest.ts | 14 + 7 files changed, 742 insertions(+), 103 deletions(-) create mode 100644 packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setTableCellsStyle.ts create mode 100644 packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/toggleTableSelection.ts create mode 100644 packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setTableCellsStyleTest.ts create mode 100644 packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/toggleTableSelectionTest.ts 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 e8b7b9a6b51..3676ed67ef2 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 00000000000..0f7788d482b --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setTableCellsStyle.ts @@ -0,0 +1,120 @@ +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 + * Set style for table cells in the selection + * @param core The EditorCore object + * @param selection The table selection + * @param style The CSS style to apply, or empty string to remove style + * @param parsedTable Optional pre-parsed table to avoid parsing twice + */ +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 selection The table selection + * @param style The CSS style to apply, or empty string to remove style + * @param parsedTable Optional pre-parsed table to avoid parsing twice + */ +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 00000000000..9941fa8e75d --- /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 4e21ae2982b..0f707935d93 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/test/coreApi/setDOMSelection/setTableCellsStyleTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setTableCellsStyleTest.ts new file mode 100644 index 00000000000..15e6a53021f --- /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 00000000000..143e76fd2c2 --- /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 5d223b3d9e8..37deb0e95fe 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); }); }); }); From 8e876ca1644e89910ef473be50901bcf14492ef4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 20:15:27 -0300 Subject: [PATCH 10/18] Fix outdated JSDoc comments in setTableCellsStyle.ts (#3278) Fix JSDoc comments for removeTableCellsStyle function to match actual parameters Fix JSDoc comments for setTableCellsStyle function to match actual parameters --- .../coreApi/setDOMSelection/setTableCellsStyle.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setTableCellsStyle.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setTableCellsStyle.ts index 0f7788d482b..d24cf146b64 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setTableCellsStyle.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setTableCellsStyle.ts @@ -7,11 +7,8 @@ const TABLE_ID = 'table'; /** * @internal - * Set style for table cells in the selection + * Remove table cell selection styles * @param core The EditorCore object - * @param selection The table selection - * @param style The CSS style to apply, or empty string to remove style - * @param parsedTable Optional pre-parsed table to avoid parsing twice */ export function removeTableCellsStyle(core: EditorCore) { core.api.setEditorStyle(core, DOM_SELECTION_CSS_KEY, ''); @@ -21,9 +18,10 @@ export function removeTableCellsStyle(core: EditorCore) { * @internal * Set style for table cells in the selection * @param core The EditorCore object - * @param selection The table selection - * @param style The CSS style to apply, or empty string to remove style - * @param parsedTable Optional pre-parsed table to avoid parsing twice + * @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, From 6699195227b0ba5d2dccd7666330704d97a6ea66 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 5 Feb 2026 08:36:00 -0800 Subject: [PATCH 11/18] Fix 329516 (#3276) Co-authored-by: Bryan Valverde U --- .../lib/publicApi/link/insertLink.ts | 5 +-- .../lib/publicApi/utils/checkXss.ts | 10 ++++++ .../test/publicApi/utils/checkXssTest.ts | 33 +++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 packages/roosterjs-content-model-api/lib/publicApi/utils/checkXss.ts create mode 100644 packages/roosterjs-content-model-api/test/publicApi/utils/checkXssTest.ts 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 807be59727c..bb70be99b2f 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/utils/checkXss.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/checkXss.ts new file mode 100644 index 00000000000..5f192bb4a17 --- /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/publicApi/utils/checkXssTest.ts b/packages/roosterjs-content-model-api/test/publicApi/utils/checkXssTest.ts new file mode 100644 index 00000000000..9069906435d --- /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); + }); +}); From 0d1a49eef9cc77289eab1e10c552077facfed4f3 Mon Sep 17 00:00:00 2001 From: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:53:36 -0300 Subject: [PATCH 12/18] [Table Improvements] Insert table content (#3258) When inserting a table in a range selection, insert the selected content inside the table. --- .../lib/modelApi/table/tableContent.ts | 87 ++ .../lib/publicApi/table/insertTable.ts | 11 +- .../test/modelApi/table/tableContentTest.ts | 323 +++++++ .../test/publicApi/table/insertTableTest.ts | 804 ++++++++++++++++++ .../lib/command/cutCopy/getContentForCopy.ts | 34 +- .../copyPaste/CopyPastePluginTest.ts | 2 +- .../domUtils/selection}/preprocessTable.ts | 2 +- .../selection}/pruneUnselectedModel.ts | 0 .../selection/trimModelForSelection.ts | 24 + .../roosterjs-content-model-dom/lib/index.ts | 1 + .../selection}/preprocessTableTest.ts | 2 +- .../selection}/pruneUnselectedModelTest.ts | 2 +- .../selection/trimModelForSelectionTest.ts | 340 ++++++++ 13 files changed, 1605 insertions(+), 27 deletions(-) create mode 100644 packages/roosterjs-content-model-api/lib/modelApi/table/tableContent.ts create mode 100644 packages/roosterjs-content-model-api/test/modelApi/table/tableContentTest.ts rename packages/{roosterjs-content-model-core/lib/command/cutCopy => roosterjs-content-model-dom/lib/domUtils/selection}/preprocessTable.ts (88%) rename packages/{roosterjs-content-model-core/lib/command/cutCopy => roosterjs-content-model-dom/lib/domUtils/selection}/pruneUnselectedModel.ts (100%) create mode 100644 packages/roosterjs-content-model-dom/lib/domUtils/selection/trimModelForSelection.ts rename packages/{roosterjs-content-model-core/test/command/cutCopy => roosterjs-content-model-dom/test/domUtils/selection}/preprocessTableTest.ts (98%) rename packages/{roosterjs-content-model-core/test/command/cutCopy => roosterjs-content-model-dom/test/domUtils/selection}/pruneUnselectedModelTest.ts (99%) create mode 100644 packages/roosterjs-content-model-dom/test/domUtils/selection/trimModelForSelectionTest.ts 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 00000000000..da31918495d --- /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/table/insertTable.ts b/packages/roosterjs-content-model-api/lib/publicApi/table/insertTable.ts index 87b6cbc6af9..9b5c9354181 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/test/modelApi/table/tableContentTest.ts b/packages/roosterjs-content-model-api/test/modelApi/table/tableContentTest.ts new file mode 100644 index 00000000000..5d5cace2d8a --- /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/insertTableTest.ts b/packages/roosterjs-content-model-api/test/publicApi/table/insertTableTest.ts index 8708ddaf9a1..30664ad4870 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-core/lib/command/cutCopy/getContentForCopy.ts b/packages/roosterjs-content-model-core/lib/command/cutCopy/getContentForCopy.ts index d17be089fb8..f881dffbf9e 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/test/corePlugin/copyPaste/CopyPastePluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/CopyPastePluginTest.ts index a36b8fcdab1..8589b1782d5 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/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 e023cc95f58..23f879f7fcf 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 00000000000..c801a94deae --- /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/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index 6a3fb64f476..1b81b754707 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-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 bbf72b77cd0..71a7eec9ab1 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 f4c4ab327ac..f1370d81ea5 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 00000000000..667e757b93b --- /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); + }); +}); From 1d914fde74d82ceefd3684d08453c39ac9a18a5c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:30:14 -0800 Subject: [PATCH 13/18] Bump webpack from 5.94.0 to 5.104.1 (#3285) Bumps [webpack](https://github.com/webpack/webpack) from 5.94.0 to 5.104.1. - [Release notes](https://github.com/webpack/webpack/releases) - [Changelog](https://github.com/webpack/webpack/blob/main/CHANGELOG.md) - [Commits](https://github.com/webpack/webpack/compare/v5.94.0...v5.104.1) --- updated-dependencies: - dependency-name: webpack dependency-version: 5.104.1 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 512 ++++++++++++++++++++++++++++----------------------- 2 files changed, 279 insertions(+), 235 deletions(-) diff --git a/package.json b/package.json index b17553c86e8..a35fd48100d 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/yarn.lock b/yarn.lock index fca09aa8980..97adedf0983 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" @@ -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" From 322306388743da5550e056ce12527f384a23539e Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Mon, 9 Feb 2026 11:12:07 -0600 Subject: [PATCH 14/18] Filter temporary EOP elements in Word Online paste and add test pattern support (#3283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Filter temporary EOP elements in Word Online paste and add test pattern support - Skip elements with both 'Selected' and 'EOP' classes during WAC paste processing to remove temporary End of Paragraph markers - Add unit tests for EOP element filtering behavior (3 test cases) - Enhance test runner with --testPathPattern and --testNamePattern flags for faster targeted test execution 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 * Update packages/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Sonnet 4.5 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- karma.fast.conf.js | 7 ++ .../processPastedContentWacComponents.ts | 8 ++- .../word/processPastedContentFromWacTest.ts | 65 +++++++++++++++++++ tools/karma.test.js | 18 +++-- 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/karma.fast.conf.js b/karma.fast.conf.js index 8058715f391..cb9f6157373 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/packages/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts b/packages/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts index 22851c36417..a780e1beb10 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/paste/word/processPastedContentFromWacTest.ts b/packages/roosterjs-content-model-plugins/test/paste/word/processPastedContentFromWacTest.ts index 9174c55d92e..6215c4db93c 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/tools/karma.test.js b/tools/karma.test.js index 22a93d2442f..7e5be9f2ad8 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 = []; From 85e2357af8d8666823dce1f72afeaae868282d32 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 9 Feb 2026 10:13:48 -0800 Subject: [PATCH 15/18] Dark color improvement (#3279) * Dark color improvement * improve --- .../lib/editor/Editor.ts | 3 +- .../test/editor/EditorTest.ts | 6 +- .../lib/domUtils/style/transformColor.ts | 32 ++- .../common/backgroundColorFormatHandler.ts | 9 +- .../lib/formatHandlers/utils/color.ts | 41 ++- .../test/domUtils/style/transformColorTest.ts | 43 +++- .../backgroundColorFormatHandlerTest.ts | 22 +- .../test/formatHandlers/utils/colorTest.ts | 234 +++++++++++++++--- .../lib/context/DarkColorHandler.ts | 4 +- .../lib/editor/EditorAdapter.ts | 6 +- 10 files changed, 329 insertions(+), 71 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index 106ca6fb19b..a0fe1140632 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/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index a646bd835d5..c6ff9050e64 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/domUtils/style/transformColor.ts b/packages/roosterjs-content-model-dom/lib/domUtils/style/transformColor.ts index 5a22a54de6b..709ea5e0114 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 a2d7577f254..f81f7af323f 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 eb68b5fe785..a4c4af14f89 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/test/domUtils/style/transformColorTest.ts b/packages/roosterjs-content-model-dom/test/domUtils/style/transformColorTest.ts index d14c0952409..a68dd6169ab 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 3e5f24ccc61..a027c04e5e7 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 86ef59307b8..745c8f52d61 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-types/lib/context/DarkColorHandler.ts b/packages/roosterjs-content-model-types/lib/context/DarkColorHandler.ts index f9f459cd992..270f2ac7083 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-editor-adapter/lib/editor/EditorAdapter.ts b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts index 5c749c40634..91b64831958 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 ); } } From a1901173ab3fe45459c25985315fe6ecb793141f Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 9 Feb 2026 10:36:34 -0800 Subject: [PATCH 16/18] Fix #3280 (#3282) --- .../lib/modelToDom/handlers/handleList.ts | 3 + .../modelToDom/handlers/handleListTest.ts | 92 +++++++++++++++++++ 2 files changed, 95 insertions(+) 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 f524d3917cf..90105c7401f 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/modelToDom/handlers/handleListTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts index 33fa3d696e0..8e449af30cc 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', () => { From 9d5742562dba09c7964b1598aa61fe704288b196 Mon Sep 17 00:00:00 2001 From: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:44:07 -0300 Subject: [PATCH 17/18] [Table Improvements] Ignore span cells when merge table cells (#3281) When merging table cells, count table that are span as one cell, so two or more cells cannot be merge to one single span cell. --- .../lib/modelApi/editing/mergeModel.ts | 143 +++++++--- .../test/modelApi/editing/mergeModelTest.ts | 260 ++++++++++++++++++ 2 files changed, 371 insertions(+), 32 deletions(-) diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts index ab02e56f741..33cba790a24 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts @@ -27,6 +27,7 @@ import type { ReadonlyContentModelBlock, ReadonlyContentModelBlockGroup, ReadonlyContentModelDocument, + ReadonlyContentModelTable, ShallowMutableContentModelParagraph, } from 'roosterjs-content-model-types'; @@ -191,44 +192,64 @@ function mergeTables( const { table: readonlyTable, colIndex, rowIndex } = tableContext; const table = mutateBlock(readonlyTable); + const newTableColCount = newTable.rows[0]?.cells.length || 0; + const newTableRowCount = newTable.rows.length; + + const lastTargetColIndex = getTargetColIndex(table, rowIndex, colIndex, newTableColCount); + const extraColsNeeded = lastTargetColIndex - table.rows[0].cells.length; + + if (extraColsNeeded > 0) { + const currentColCount = table.rows[0].cells.length; + for (let col = 0; col < extraColsNeeded; col++) { + const newColIndex = currentColCount + col; + for (let k = 0; k < table.rows.length; k++) { + const leftCell = table.rows[k]?.cells[newColIndex - 1]; + table.rows[k].cells[newColIndex] = createTableCell( + false /*spanLeft*/, + false /*spanAbove*/, + leftCell?.isHeader, + leftCell?.format + ); + } + } + } + + const lastTargetRowIndex = getTargetRowIndex(table, rowIndex, newTableRowCount, colIndex); + const extraRowsNeeded = lastTargetRowIndex - table.rows.length; + + if (extraRowsNeeded > 0) { + const currentRowCount = table.rows.length; + const colCount = table.rows[0]?.cells.length || 0; + for (let row = 0; row < extraRowsNeeded; row++) { + const newRowIndex = currentRowCount + row; + table.rows[newRowIndex] = { + cells: [], + format: {}, + height: 0, + }; + for (let k = 0; k < colCount; k++) { + const aboveCell = table.rows[newRowIndex - 1]?.cells[k]; + table.rows[newRowIndex].cells[k] = createTableCell( + false /*spanLeft*/, + false /*spanAbove*/, + false /*isHeader*/, + aboveCell?.format + ); + } + } + } + for (let i = 0; i < newTable.rows.length; i++) { + const targetRowIndex = getTargetRowIndex(table, rowIndex, i, colIndex); + for (let j = 0; j < newTable.rows[i].cells.length; j++) { const newCell = newTable.rows[i].cells[j]; - if (i == 0 && colIndex + j >= table.rows[0].cells.length) { - for (let k = 0; k < table.rows.length; k++) { - const leftCell = table.rows[k]?.cells[colIndex + j - 1]; - table.rows[k].cells[colIndex + j] = createTableCell( - false /*spanLeft*/, - false /*spanAbove*/, - leftCell?.isHeader, - leftCell?.format - ); - } - } - - if (j == 0 && rowIndex + i >= table.rows.length) { - if (!table.rows[rowIndex + i]) { - table.rows[rowIndex + i] = { - cells: [], - format: {}, - height: 0, - }; - } + const targetColIndex = getTargetColIndex(table, targetRowIndex, colIndex, j); - for (let k = 0; k < table.rows[rowIndex].cells.length; k++) { - const aboveCell = table.rows[rowIndex + i - 1]?.cells[k]; - table.rows[rowIndex + i].cells[k] = createTableCell( - false /*spanLeft*/, - false /*spanAbove*/, - false /*isHeader*/, - aboveCell?.format - ); - } - } + const oldCell = table.rows[targetRowIndex]?.cells[targetColIndex]; - const oldCell = table.rows[rowIndex + i].cells[colIndex + j]; - table.rows[rowIndex + i].cells[colIndex + j] = newCell; + table.rows[targetRowIndex].cells[targetColIndex] = newCell; if (i == 0 && j == 0) { const newMarker = createSelectionMarker(marker.format); @@ -494,6 +515,7 @@ function getFormatWithoutSegmentFormat( KeysOfSegmentFormat.forEach(key => delete resultFormat[key]); return resultFormat; } + function getHyperlinkTextColor(sourceFormat: ContentModelHyperLinkFormat) { const result: ContentModelHyperLinkFormat = {}; if (sourceFormat.textColor) { @@ -502,3 +524,60 @@ function getHyperlinkTextColor(sourceFormat: ContentModelHyperLinkFormat) { return result; } + +function getTargetColIndex( + table: ReadonlyContentModelTable, + rowIndex: number, + startColIndex: number, + offset: number +): number { + const row = table.rows[rowIndex]; + if (!row) { + return startColIndex + offset; + } + + if (offset === 0) { + return startColIndex; + } + + let targetColIndex = startColIndex; + let logicalCellsToSkip = offset; + + while (logicalCellsToSkip > 0) { + targetColIndex++; + + if (targetColIndex >= row.cells.length) { + logicalCellsToSkip--; + } else if (!row.cells[targetColIndex].spanLeft) { + logicalCellsToSkip--; + } + } + + return targetColIndex; +} + +function getTargetRowIndex( + table: ReadonlyContentModelTable, + startRowIndex: number, + offset: number, + colIndex: number +): number { + if (offset === 0) { + return startRowIndex; + } + + let targetRowIndex = startRowIndex; + let logicalRowsToSkip = offset; + + while (logicalRowsToSkip > 0) { + targetRowIndex++; + + if (targetRowIndex >= table.rows.length) { + logicalRowsToSkip--; + } else if (!table.rows[targetRowIndex]?.cells[colIndex]?.spanAbove) { + logicalRowsToSkip--; + } + } + + return targetRowIndex; +} diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts index 6dfb579cac5..ed1af5dc629 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts @@ -6041,4 +6041,264 @@ describe('mergeModel', () => { }); // #endregion + + // #region Merge table with spanLeft and spanAbove + + it('table to table, merge table with spanLeft cell - should skip span cells', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + + // Create a 2x3 table where columns 1-2 are merged (spanLeft) + // Selection is in cell12 (which is spanLeft, part of merged cell01-02) + const para1 = createParagraph(); + const text1 = createText('test1'); + const cell01 = createTableCell(false, false, false, { backgroundColor: '01' }); + const cell02 = createTableCell(true, false, false, { backgroundColor: '02' }); // spanLeft + const cell03 = createTableCell(false, false, false, { backgroundColor: '03' }); + const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); + const cell12 = createTableCell(true, false, false, { backgroundColor: '12' }); // spanLeft + const cell13 = createTableCell(false, false, false, { backgroundColor: '13' }); + const table1 = createTable(2); + + para1.segments.push(text1); + text1.isSelected = true; + cell12.blocks.push(para1); + table1.rows = [ + { format: {}, height: 0, cells: [cell01, cell02, cell03] }, + { format: {}, height: 0, cells: [cell11, cell12, cell13] }, + ]; + + majorModel.blocks.push(table1); + + // Source table has 2 cells - they should be placed at positions 1 and 2 (skipping span) + const newPara1 = createParagraph(); + const newText1 = createText('newText1'); + const newCell11 = createTableCell(false, false, false, { backgroundColor: 'n11' }); + const newCell12 = createTableCell(false, false, false, { backgroundColor: 'n12' }); + const newTable1 = createTable(1); + + newPara1.segments.push(newText1); + newCell11.blocks.push(newPara1); + newTable1.rows = [{ format: {}, height: 0, cells: [newCell11, newCell12] }]; + + sourceModel.blocks.push(newTable1); + + spyOn(applyTableFormat, 'applyTableFormat'); + spyOn(normalizeTable, 'normalizeTable'); + + mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeTable: true, + } + ); + + const table = majorModel.blocks[0] as ContentModelTable; + + // The first new cell should replace cell12 (at index 1), second should go to index 2 + expect(table.rows[1].cells[1]).toBe(newCell11); + expect(table.rows[1].cells[2]).toBe(newCell12); + expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); + }); + + it('table to table, merge table with spanAbove cell - should skip span cells', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + + // Create a 3x2 table where rows 0-1 are merged (spanAbove) at column 1 + const para1 = createParagraph(); + const text1 = createText('test1'); + const cell01 = createTableCell(false, false, false, { backgroundColor: '01' }); + const cell02 = createTableCell(false, false, false, { backgroundColor: '02' }); + const cell11 = createTableCell(false, true, false, { backgroundColor: '11' }); // spanAbove + const cell12 = createTableCell(false, true, false, { backgroundColor: '12' }); // spanAbove + const cell21 = createTableCell(false, false, false, { backgroundColor: '21' }); + const cell22 = createTableCell(false, false, false, { backgroundColor: '22' }); + const table1 = createTable(3); + + para1.segments.push(text1); + text1.isSelected = true; + cell12.blocks.push(para1); + table1.rows = [ + { format: {}, height: 0, cells: [cell01, cell02] }, + { format: {}, height: 0, cells: [cell11, cell12] }, + { format: {}, height: 0, cells: [cell21, cell22] }, + ]; + + majorModel.blocks.push(table1); + + // Source table has 2 rows - they should be placed at row 1 and 2 (skipping spanAbove) + const newPara1 = createParagraph(); + const newText1 = createText('newText1'); + const newCell11 = createTableCell(false, false, false, { backgroundColor: 'n11' }); + const newCell21 = createTableCell(false, false, false, { backgroundColor: 'n21' }); + const newTable1 = createTable(2); + + newPara1.segments.push(newText1); + newCell11.blocks.push(newPara1); + newTable1.rows = [ + { format: {}, height: 0, cells: [newCell11] }, + { format: {}, height: 0, cells: [newCell21] }, + ]; + + sourceModel.blocks.push(newTable1); + + spyOn(applyTableFormat, 'applyTableFormat'); + spyOn(normalizeTable, 'normalizeTable'); + + mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeTable: true, + } + ); + + const table = majorModel.blocks[0] as ContentModelTable; + + // First new cell should replace cell12 at row 1, second should go to row 2 + expect(table.rows[1].cells[1]).toBe(newCell11); + expect(table.rows[2].cells[1]).toBe(newCell21); + expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); + }); + + it('table to table, merge 2x2 table into cell with spanLeft - should expand and skip spans', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + + // Create a 2x3 table where columns 1-2 are merged (spanLeft) + const para1 = createParagraph(); + const text1 = createText('test1'); + const cell01 = createTableCell(false, false, false, { backgroundColor: '01' }); + const cell02 = createTableCell(true, false, false, { backgroundColor: '02' }); // spanLeft + const cell03 = createTableCell(false, false, false, { backgroundColor: '03' }); + const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); + const cell12 = createTableCell(true, false, false, { backgroundColor: '12' }); // spanLeft + const cell13 = createTableCell(false, false, false, { backgroundColor: '13' }); + const table1 = createTable(2); + + para1.segments.push(text1); + text1.isSelected = true; + cell12.blocks.push(para1); + table1.rows = [ + { format: {}, height: 0, cells: [cell01, cell02, cell03] }, + { format: {}, height: 0, cells: [cell11, cell12, cell13] }, + ]; + + majorModel.blocks.push(table1); + + // Source table is 2x2 + const newPara1 = createParagraph(); + const newText1 = createText('newText1'); + const newCell11 = createTableCell(false, false, false, { backgroundColor: 'n11' }); + const newCell12 = createTableCell(false, false, false, { backgroundColor: 'n12' }); + const newCell21 = createTableCell(false, false, false, { backgroundColor: 'n21' }); + const newCell22 = createTableCell(false, false, false, { backgroundColor: 'n22' }); + const newTable1 = createTable(2); + + newPara1.segments.push(newText1); + newCell11.blocks.push(newPara1); + newTable1.rows = [ + { format: {}, height: 0, cells: [newCell11, newCell12] }, + { format: {}, height: 0, cells: [newCell21, newCell22] }, + ]; + + sourceModel.blocks.push(newTable1); + + spyOn(applyTableFormat, 'applyTableFormat'); + spyOn(normalizeTable, 'normalizeTable'); + + mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeTable: true, + } + ); + + const table = majorModel.blocks[0] as ContentModelTable; + + // New cells should be placed correctly, skipping the spanLeft cell + expect(table.rows[1].cells[1]).toBe(newCell11); + expect(table.rows[1].cells[2]).toBe(newCell12); + // Second row of pasted table should go to row 2 + expect(table.rows.length).toBe(3); + expect(table.rows[2].cells[1]).toBe(newCell21); + expect(table.rows[2].cells[2]).toBe(newCell22); + expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); + }); + + it('table to table, merge 2x2 table into cell with spanAbove - should expand and skip spans', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + + // Create a 3x2 table where rows 0-1 are merged (spanAbove) at column 1 + const para1 = createParagraph(); + const text1 = createText('test1'); + const cell01 = createTableCell(false, false, false, { backgroundColor: '01' }); + const cell02 = createTableCell(false, false, false, { backgroundColor: '02' }); + const cell11 = createTableCell(false, true, false, { backgroundColor: '11' }); // spanAbove + const cell12 = createTableCell(false, true, false, { backgroundColor: '12' }); // spanAbove + const cell21 = createTableCell(false, false, false, { backgroundColor: '21' }); + const cell22 = createTableCell(false, false, false, { backgroundColor: '22' }); + const table1 = createTable(3); + + para1.segments.push(text1); + text1.isSelected = true; + cell12.blocks.push(para1); + table1.rows = [ + { format: {}, height: 0, cells: [cell01, cell02] }, + { format: {}, height: 0, cells: [cell11, cell12] }, + { format: {}, height: 0, cells: [cell21, cell22] }, + ]; + + majorModel.blocks.push(table1); + + // Source table is 2x2 + const newPara1 = createParagraph(); + const newText1 = createText('newText1'); + const newCell11 = createTableCell(false, false, false, { backgroundColor: 'n11' }); + const newCell12 = createTableCell(false, false, false, { backgroundColor: 'n12' }); + const newCell21 = createTableCell(false, false, false, { backgroundColor: 'n21' }); + const newCell22 = createTableCell(false, false, false, { backgroundColor: 'n22' }); + const newTable1 = createTable(2); + + newPara1.segments.push(newText1); + newCell11.blocks.push(newPara1); + newTable1.rows = [ + { format: {}, height: 0, cells: [newCell11, newCell12] }, + { format: {}, height: 0, cells: [newCell21, newCell22] }, + ]; + + sourceModel.blocks.push(newTable1); + + spyOn(applyTableFormat, 'applyTableFormat'); + spyOn(normalizeTable, 'normalizeTable'); + + mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeTable: true, + } + ); + + const table = majorModel.blocks[0] as ContentModelTable; + + // New cells should be placed correctly, skipping the spanAbove cell + // Table should have expanded to 3 columns + expect(table.rows[0].cells.length).toBe(3); + expect(table.rows[1].cells[1]).toBe(newCell11); + expect(table.rows[1].cells[2]).toBe(newCell12); + expect(table.rows[2].cells[1]).toBe(newCell21); + expect(table.rows[2].cells[2]).toBe(newCell22); + expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); + }); + + // #endregion }); From fb9a8a29659651b4a05068f04564397295dffe5e Mon Sep 17 00:00:00 2001 From: "SOUTHAMERICA\\bvalverde" Date: Wed, 11 Feb 2026 17:32:46 -0600 Subject: [PATCH 18/18] Bump main and legacyAdapter versions in versions.json --- versions.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/versions.json b/versions.json index 6aa6a4fe9a6..529074ebed2 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": {} }