From 069e5a13089317ac07a7fd30ab8d2964ed1385fa Mon Sep 17 00:00:00 2001 From: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:22:24 -0300 Subject: [PATCH 1/8] merge tables (#3461) Move canMergeCells into the content-model DOM package so format-state retrieval and table mutation APIs share the same span validation. Update retrieveModelFormatState and the demo merge control so selections that would split existing merged cells report canMergeTableCell: false and disable the merge action. --- .../demoButtons/tableEditButtons.ts | 2 +- .../lib/modelApi/table/mergeTableCells.ts | 3 +-- .../lib/modelApi/table/mergeTableColumn.ts | 3 +-- .../lib/modelApi/table/mergeTableRow.ts | 3 +-- .../roosterjs-content-model-dom/lib/index.ts | 1 + .../lib/modelApi/editing}/canMergeCells.ts | 13 ++++++---- .../editing/retrieveModelFormatState.ts | 14 ++++++++++- .../modelApi/editing}/canMergeCellsTest.ts | 2 +- .../editing/retrieveModelFormatStateTest.ts | 24 +++++++++++++++++++ 9 files changed, 52 insertions(+), 13 deletions(-) rename packages/{roosterjs-content-model-api/lib/modelApi/table => roosterjs-content-model-dom/lib/modelApi/editing}/canMergeCells.ts (79%) rename packages/{roosterjs-content-model-api/test/modelApi/table => roosterjs-content-model-dom/test/modelApi/editing}/canMergeCellsTest.ts (98%) diff --git a/demo/scripts/controlsV2/demoButtons/tableEditButtons.ts b/demo/scripts/controlsV2/demoButtons/tableEditButtons.ts index 764a04d04901..27f130d460eb 100644 --- a/demo/scripts/controlsV2/demoButtons/tableEditButtons.ts +++ b/demo/scripts/controlsV2/demoButtons/tableEditButtons.ts @@ -95,7 +95,7 @@ export const tableMergeButton: RibbonButton< key: 'ribbonButtonTableMerge', iconName: '', unlocalizedText: 'Merge', - isDisabled: formatState => !formatState.isInTable, + isDisabled: formatState => !formatState.isInTable || !formatState.canMergeTableCell, dropDownMenu: { items: { menuNameTableMergeAbove: 'Merge above', diff --git a/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableCells.ts b/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableCells.ts index a732c2262531..f49e01927608 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableCells.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableCells.ts @@ -1,5 +1,4 @@ -import { canMergeCells } from './canMergeCells'; -import { getSelectedCells, mutateBlock } from 'roosterjs-content-model-dom'; +import { getSelectedCells, mutateBlock, canMergeCells } from 'roosterjs-content-model-dom'; import type { ShallowMutableContentModelTable } from 'roosterjs-content-model-types'; /** diff --git a/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableColumn.ts b/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableColumn.ts index c904d96ff6a9..dded0c265141 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableColumn.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableColumn.ts @@ -1,5 +1,4 @@ -import { canMergeCells } from './canMergeCells'; -import { getSelectedCells, mutateBlock } from 'roosterjs-content-model-dom'; +import { getSelectedCells, mutateBlock, canMergeCells } from 'roosterjs-content-model-dom'; import type { ShallowMutableContentModelTable, TableHorizontalMergeOperation, diff --git a/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableRow.ts b/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableRow.ts index 6f7d298bd6f1..a17f03fe606e 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableRow.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/table/mergeTableRow.ts @@ -1,5 +1,4 @@ -import { canMergeCells } from './canMergeCells'; -import { getSelectedCells, mutateBlock } from 'roosterjs-content-model-dom'; +import { getSelectedCells, mutateBlock, canMergeCells } from 'roosterjs-content-model-dom'; import type { ShallowMutableContentModelTable, TableVerticalMergeOperation, diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index 629d393c3cfe..5250abd08d9e 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -152,6 +152,7 @@ export { hasSelectionInBlockGroup } from './modelApi/selection/hasSelectionInBlo export { setSelection } from './modelApi/selection/setSelection'; export { cloneModel, cloneModelForPaste } from './modelApi/editing/cloneModel'; +export { canMergeCells } from './modelApi/editing/canMergeCells'; export { mergeModel } from './modelApi/editing/mergeModel'; export { deleteSelection } from './modelApi/editing/deleteSelection'; export { deleteSegment } from './modelApi/editing/deleteSegment'; diff --git a/packages/roosterjs-content-model-api/lib/modelApi/table/canMergeCells.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/canMergeCells.ts similarity index 79% rename from packages/roosterjs-content-model-api/lib/modelApi/table/canMergeCells.ts rename to packages/roosterjs-content-model-dom/lib/modelApi/editing/canMergeCells.ts index 1c14ab605a72..a9fae829f55c 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/table/canMergeCells.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/canMergeCells.ts @@ -1,10 +1,15 @@ import type { ReadonlyContentModelTableRow } from 'roosterjs-content-model-types'; /** - * @internal + * Check whether the given range of table cells can be merged without splitting existing spans. + * @param rows The table rows to check + * @param firstRow The first row index of the range + * @param firstCol The first column index of the range + * @param lastRow The last row index of the range + * @param lastCol The last column index of the range */ export function canMergeCells( - rows: ReadonlyContentModelTableRow[], + rows: readonly ReadonlyContentModelTableRow[], firstRow: number, firstCol: number, lastRow: number, @@ -41,7 +46,7 @@ export function canMergeCells( } function getBelowSpanCount( - rows: ReadonlyContentModelTableRow[], + rows: readonly ReadonlyContentModelTableRow[], rowIndex: number, colIndex: number ) { @@ -59,7 +64,7 @@ function getBelowSpanCount( } function getRightSpanCount( - rows: ReadonlyContentModelTableRow[], + rows: readonly ReadonlyContentModelTableRow[], rowIndex: number, colIndex: number ) { diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts index 47c404f68046..baae3491cb6f 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/retrieveModelFormatState.ts @@ -1,4 +1,5 @@ import { extractBorderValues } from '../../domUtils/style/borderValues'; +import { canMergeCells } from './canMergeCells'; import { getClosestAncestorBlockGroupIndex } from './getClosestAncestorBlockGroupIndex'; import { getImageMetadata } from '../metadata/updateImageMetadata'; import { getTableMetadata } from '../metadata/updateTableMetadata'; @@ -42,6 +43,7 @@ export function retrieveModelFormatState( colorHandler?: DarkColorHandler ) { let firstTableContext: ReadonlyTableSelectionContext | undefined; + let lastTableContext: ReadonlyTableSelectionContext | undefined; let firstBlock: ReadonlyContentModelBlock | undefined; let isFirst = true; let isFirstImage = true; @@ -151,7 +153,7 @@ export function retrieveModelFormatState( tableContext.table == table && (tableContext.colIndex != colIndex || tableContext.rowIndex != rowIndex) ) { - formatState.canMergeTableCell = true; + lastTableContext = tableContext; formatState.isMultilineSelection = true; } } else { @@ -166,6 +168,16 @@ export function retrieveModelFormatState( } ); + if (firstTableContext && lastTableContext) { + formatState.canMergeTableCell = canMergeCells( + firstTableContext.table.rows, + firstTableContext.rowIndex, + firstTableContext.colIndex, + lastTableContext.rowIndex, + lastTableContext.colIndex + ); + } + if (formatState.fontSize) { formatState.fontSize = px2Pt(formatState.fontSize); } diff --git a/packages/roosterjs-content-model-api/test/modelApi/table/canMergeCellsTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/canMergeCellsTest.ts similarity index 98% rename from packages/roosterjs-content-model-api/test/modelApi/table/canMergeCellsTest.ts rename to packages/roosterjs-content-model-dom/test/modelApi/editing/canMergeCellsTest.ts index 506b163f5c25..854999dae871 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/table/canMergeCellsTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/canMergeCellsTest.ts @@ -1,4 +1,4 @@ -import { canMergeCells } from '../../../lib/modelApi/table/canMergeCells'; +import { canMergeCells } from '../../../lib/modelApi/editing/canMergeCells'; import { createTable, createTableCell } from 'roosterjs-content-model-dom'; describe('canMergeCells', () => { diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts index 427e64b34524..a5e424726436 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/retrieveModelFormatStateTest.ts @@ -548,6 +548,30 @@ describe('retrieveModelFormatState', () => { }); }); + it('With selected table cells crossing a split cell', () => { + const model = createContentModelDocument(); + const result: ContentModelFormatState = {}; + const cell1 = createTableCell(); + const cell2 = createTableCell(false, true); + const table = createTable(2); + + cell1.isSelected = true; + cell2.isSelected = true; + table.rows[0].cells.push(createTableCell(), createTableCell()); + table.rows[1].cells.push(cell1, cell2); + model.blocks.push(table); + + retrieveModelFormatState(model, null, result); + + expect(result).toEqual({ + isInTable: true, + tableHasHeader: false, + isMultilineSelection: true, + canMergeTableCell: false, + isBlockQuote: false, + }); + }); + it('With multiple table cell selected, multiple content is in table cell', () => { const model = createContentModelDocument(); const result: ContentModelFormatState = {}; From 339f6bf9002e11797fcb2abb0eae7e3ae7e47617 Mon Sep 17 00:00:00 2001 From: Vi Nguyen <36.tuongvi@gmail.com> Date: Mon, 14 Sep 2026 13:24:29 -0700 Subject: [PATCH 2/8] Add JSON Beautifier to side pane --- demo/scripts/controlsV2/mainPane/MainPane.tsx | 4 + .../JsonBeautifier/JsonBeautifierPane.scss | 17 ++++ .../JsonBeautifier/JsonBeautifierPane.tsx | 88 +++++++++++++++++++ .../JsonBeautifierPanePlugin.ts | 26 ++++++ 4 files changed, 135 insertions(+) create mode 100644 demo/scripts/controlsV2/sidePane/JsonBeautifier/JsonBeautifierPane.scss create mode 100644 demo/scripts/controlsV2/sidePane/JsonBeautifier/JsonBeautifierPane.tsx create mode 100644 demo/scripts/controlsV2/sidePane/JsonBeautifier/JsonBeautifierPanePlugin.ts diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index e8a3945195a0..926b0f67a0d8 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -20,6 +20,7 @@ import { getTabs, tabNames } from '../tabs/getTabs'; import { getTheme } from '../theme/themes'; import { MarkdownPanePlugin } from '../sidePane/MarkdownPane/MarkdownPanePlugin'; import { MarkdownPastePlugin } from 'roosterjs-content-model-markdown'; +import { JsonBeautifierPanePlugin } from '../sidePane/JsonBeautifier/JsonBeautifierPanePlugin'; import { OptionState, UrlPlaceholder } from '../sidePane/editorOptions/OptionState'; import { popoutButton } from '../demoButtons/popoutButton'; import { PresetPlugin } from '../sidePane/presets/PresetPlugin'; @@ -116,6 +117,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { private samplePickerPlugin: SamplePickerPlugin; private snapshots: Snapshots; private markdownPanePlugin: MarkdownPanePlugin; + private jsonBeautifierPanePlugin: JsonBeautifierPanePlugin; private findReplacePlugin: FindReplacePlugin; private findReplaceContext: FindReplaceContext; private imageEditPlugin: ImageEditPlugin | undefined; @@ -157,6 +159,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { this.formatPainterPlugin = new FormatPainterPlugin(); this.samplePickerPlugin = new SamplePickerPlugin(); this.markdownPanePlugin = new MarkdownPanePlugin(); + this.jsonBeautifierPanePlugin = new JsonBeautifierPanePlugin(); this.findReplaceContext = createFindReplaceContext(); this.findReplacePlugin = new FindReplacePlugin(this.findReplaceContext); @@ -552,6 +555,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { this.contentModelPanePlugin, this.presetPlugin, this.markdownPanePlugin, + this.jsonBeautifierPanePlugin, ]; } diff --git a/demo/scripts/controlsV2/sidePane/JsonBeautifier/JsonBeautifierPane.scss b/demo/scripts/controlsV2/sidePane/JsonBeautifier/JsonBeautifierPane.scss new file mode 100644 index 000000000000..dd23ae3b1262 --- /dev/null +++ b/demo/scripts/controlsV2/sidePane/JsonBeautifier/JsonBeautifierPane.scss @@ -0,0 +1,17 @@ +.container { + flex-direction: column; + display: flex; + height: 100%; +} + +.textArea { + flex: 1; + width: 100%; + box-sizing: border-box; + resize: none; +} + +.errorMessage { + color: #a80000; + margin: 4px 0; +} diff --git a/demo/scripts/controlsV2/sidePane/JsonBeautifier/JsonBeautifierPane.tsx b/demo/scripts/controlsV2/sidePane/JsonBeautifier/JsonBeautifierPane.tsx new file mode 100644 index 000000000000..f8e7f6f17fdd --- /dev/null +++ b/demo/scripts/controlsV2/sidePane/JsonBeautifier/JsonBeautifierPane.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import { + createEmptyModel, + createFormatContainer, + createParagraph, + createText, +} from 'roosterjs-content-model-dom'; +import type { JsonBeautifierPaneProps } from './JsonBeautifierPanePlugin'; + +const styles = require('./JsonBeautifierPane.scss'); + +interface JsonBeautifierPaneState { + errorMessage: string; +} + +export default class JsonBeautifierPane extends React.Component< + JsonBeautifierPaneProps, + JsonBeautifierPaneState +> { + private json = React.createRef(); + + constructor(props: JsonBeautifierPaneProps) { + super(props); + this.state = { errorMessage: '' }; + } + + private beautify = () => { + let formattedJson: string; + + try { + formattedJson = JSON.stringify(JSON.parse(this.json.current.value), null, 2); + } catch (error) { + this.setState({ + errorMessage: error instanceof Error ? error.message : 'Invalid JSON', + }); + return; + } + + const container = createFormatContainer('pre'); + const paragraph = createParagraph(true); + + paragraph.segments.push(createText(formattedJson)); + container.blocks.push(paragraph); + this.props.getEditor().formatContentModel(model => { + model.blocks = [container]; + return true; + }); + this.setState({ errorMessage: '' }); + }; + + private clear = () => { + this.json.current.value = ''; + this.setState({ errorMessage: '' }); + }; + + private clearEditor = () => { + const editor = this.props.getEditor(); + editor.formatContentModel(model => { + model.blocks = createEmptyModel().blocks; + return true; + }); + }; + + render() { + return ( +
+

Enter JSON to format and display in the editor.

+