Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2ba2441
Fix publish file (#3267)
juliaroldi Jan 22, 2026
0c78d30
[Table Improvements] Add undoSnapshot when tab on a table cell (#3265)
juliaroldi Jan 23, 2026
df8af64
[Table Improvements] Use keyboard to delete rows and columns (#3270)
juliaroldi Jan 29, 2026
5794d0a
[Table Improvements] Add Shift Cells Table Operation (#3271)
juliaroldi Feb 2, 2026
abc43d1
align table cell list (#3275)
juliaroldi Feb 3, 2026
864f6ff
fill gaps (#3272)
juliaroldi Feb 4, 2026
8cc4cbf
Bump lodash from 4.17.21 to 4.17.23 (#3266)
dependabot[bot] Feb 4, 2026
01f1d23
fix table format (#3277)
juliaroldi Feb 4, 2026
e141792
[Table Improvements] Add preview for table cell selection (#3274)
juliaroldi Feb 4, 2026
8e876ca
Fix outdated JSDoc comments in setTableCellsStyle.ts (#3278)
Copilot Feb 4, 2026
6699195
Fix 329516 (#3276)
JiuqingSong Feb 5, 2026
0d1a49e
[Table Improvements] Insert table content (#3258)
juliaroldi Feb 6, 2026
1d914fd
Bump webpack from 5.94.0 to 5.104.1 (#3285)
dependabot[bot] Feb 9, 2026
3223063
Filter temporary EOP elements in Word Online paste and add test patte…
BryanValverdeU Feb 9, 2026
85e2357
Dark color improvement (#3279)
JiuqingSong Feb 9, 2026
a190117
Fix #3280 (#3282)
JiuqingSong Feb 9, 2026
9d57425
[Table Improvements] Ignore span cells when merge table cells (#3281)
juliaroldi Feb 9, 2026
a8d727b
Merge branch 'master' of https://github.com/microsoft/roosterjs into …
BryanValverdeU Feb 11, 2026
fb9a8a2
Bump main and legacyAdapter versions in versions.json
BryanValverdeU Feb 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion demo/scripts/controlsV2/demoButtons/tableEditButtons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import type {
TableEditSplitMenuItemStringKey,
} from 'roosterjs-react';

const TableEditOperationMap: Partial<Record<TableEditMenuItemStringKey, TableOperation>> = {
type DemoTableEditMenuItemStringKey =
| TableEditMenuItemStringKey
| 'menuNameTableShiftCellsUp'
| 'menuNameTableShiftCellsLeft';

const TableEditOperationMap: Partial<Record<DemoTableEditMenuItemStringKey, TableOperation>> = {
menuNameTableInsertAbove: 'insertAbove',
menuNameTableInsertBelow: 'insertBelow',
menuNameTableInsertLeft: 'insertLeft',
Expand All @@ -35,6 +40,8 @@ const TableEditOperationMap: Partial<Record<TableEditMenuItemStringKey, TableOpe
menuNameTableAlignTableLeft: 'alignLeft',
menuNameTableAlignTableCenter: 'alignCenter',
menuNameTableAlignTableRight: 'alignRight',
menuNameTableShiftCellsUp: 'shiftCellsUp',
menuNameTableShiftCellsLeft: 'shiftCellsLeft',
};

export const tableInsertButton: RibbonButton<
Expand Down Expand Up @@ -71,6 +78,8 @@ export const tableDeleteButton: RibbonButton<
menuNameTableDeleteColumn: 'Delete column',
menuNameTableDeleteRow: 'Delete row',
menuNameTableDeleteTable: 'Delete table',
menuNameTableShiftCellsUp: 'Shift cells up',
menuNameTableShiftCellsLeft: 'Shift cells left',
},
},
onClick: (editor, key) => {
Expand Down
7 changes: 7 additions & 0 deletions karma.fast.conf.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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'],
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
});
}

Expand Down Expand Up @@ -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;
}
});
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = [];
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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++;
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { adjustTrailingSpaceSelection } from '../../modelApi/selection/adjustTrailingSpaceSelection';
import { checkXss } from '../utils/checkXss';
import { matchLink } from '../../modelApi/link/matchLink';
import {
addLink,
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -87,6 +88,11 @@ export function editTable(editor: IEditor, operation: TableOperation) {
case 'splitVertically':
splitTableCellVertically(tableModel);
break;

case 'shiftCellsUp':
case 'shiftCellsLeft':
shiftCells(tableModel, operation);
break;
}
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -38,25 +39,33 @@ 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 };
}

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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading