From c8956dab9a35c7ec9a38fe444e68078c4464a308 Mon Sep 17 00:00:00 2001 From: marekgit200 Date: Tue, 16 Jun 2026 12:37:50 +0200 Subject: [PATCH] fix(document-api): make tables tracked-mode capability honest tables.insertRow/deleteRow/insertColumn/deleteColumn all reported capabilities()..tracked=true, but only insertRow could produce a tracked change. The others silently applied direct edits under changeMode:'tracked', and tables.deleteRow physically removed the row with no w:del marker (data loss in suggesting mode). Root cause: the inline overlap compiler cannot represent tableRow insert/delete steps, and the review model only treats a whole-table insert/delete as decidable (structuralRowChanges.js). insertRow keeps tracked support: it stamps a rowInsert revision that exports as a w:ins marker, and rows from one call share a single revisionGroupId. deleteRow/insertColumn/deleteColumn now reject changeMode:tracked with CAPABILITY_UNAVAILABLE and report supportsTrackedMode:false. Reference docs regenerated. Closes #3594 --- .../reference/_generated-manifest.json | 2 +- .../reference/capabilities/get.mdx | 6 +- .../reference/tables/delete-column.mdx | 2 +- .../reference/tables/delete-row.mdx | 2 +- .../document-api/reference/tables/index.mdx | 6 +- .../reference/tables/insert-column.mdx | 2 +- .../src/contract/operation-definitions.ts | 20 ++- .../contract-conformance.test.ts | 64 ++++--- .../tables-adapter.convenience.test.ts | 162 +++++++++++++++++- .../document-api-adapters/tables-adapter.ts | 118 ++++++++++--- 10 files changed, 329 insertions(+), 55 deletions(-) diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 1b8bb06f03..00e41d3267 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1090,5 +1090,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "b7a0382edb33e88b28f1af627a89d6a0b441890883e30993390a76c756214e4c" + "sourceHash": "b03f74e26212692ef1ae1b4fd5a6f497588c8524a993267c43ea79338cf5733b" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index e0da7062f3..1e8a021e65 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -4394,12 +4394,12 @@ _No fields._ "tables.deleteColumn": { "available": true, "dryRun": true, - "tracked": true + "tracked": false }, "tables.deleteRow": { "available": true, "dryRun": true, - "tracked": true + "tracked": false }, "tables.distributeColumns": { "available": true, @@ -4439,7 +4439,7 @@ _No fields._ "tables.insertColumn": { "available": true, "dryRun": true, - "tracked": true + "tracked": false }, "tables.insertRow": { "available": true, diff --git a/apps/docs/document-api/reference/tables/delete-column.mdx b/apps/docs/document-api/reference/tables/delete-column.mdx index 4dd01bf0fd..5da1e2133b 100644 --- a/apps/docs/document-api/reference/tables/delete-column.mdx +++ b/apps/docs/document-api/reference/tables/delete-column.mdx @@ -14,7 +14,7 @@ Delete a column from the target table. - API member path: `editor.doc.tables.deleteColumn(...)` - Mutates document: `yes` - Idempotency: `conditional` -- Supports tracked mode: `yes` +- Supports tracked mode: `no` - Supports dry run: `yes` - Deterministic target resolution: `yes` diff --git a/apps/docs/document-api/reference/tables/delete-row.mdx b/apps/docs/document-api/reference/tables/delete-row.mdx index 68726016cd..abf2cbb202 100644 --- a/apps/docs/document-api/reference/tables/delete-row.mdx +++ b/apps/docs/document-api/reference/tables/delete-row.mdx @@ -14,7 +14,7 @@ Delete a row from the target table. - API member path: `editor.doc.tables.deleteRow(...)` - Mutates document: `yes` - Idempotency: `conditional` -- Supports tracked mode: `yes` +- Supports tracked mode: `no` - Supports dry run: `yes` - Deterministic target resolution: `yes` diff --git a/apps/docs/document-api/reference/tables/index.mdx b/apps/docs/document-api/reference/tables/index.mdx index d0d249ae23..f657149a12 100644 --- a/apps/docs/document-api/reference/tables/index.mdx +++ b/apps/docs/document-api/reference/tables/index.mdx @@ -24,12 +24,12 @@ For non-destructive table-targeted mutations, reuse `result.table.nodeId` from t | tables.convertToText | `tables.convertToText` | Yes | `conditional` | No | Yes | | tables.setLayout | `tables.setLayout` | Yes | `idempotent` | No | Yes | | tables.insertRow | `tables.insertRow` | Yes | `non-idempotent` | Yes | Yes | -| tables.deleteRow | `tables.deleteRow` | Yes | `conditional` | Yes | Yes | +| tables.deleteRow | `tables.deleteRow` | Yes | `conditional` | No | Yes | | tables.setRowHeight | `tables.setRowHeight` | Yes | `idempotent` | No | Yes | | tables.distributeRows | `tables.distributeRows` | Yes | `conditional` | No | Yes | | tables.setRowOptions | `tables.setRowOptions` | Yes | `idempotent` | No | Yes | -| tables.insertColumn | `tables.insertColumn` | Yes | `non-idempotent` | Yes | Yes | -| tables.deleteColumn | `tables.deleteColumn` | Yes | `conditional` | Yes | Yes | +| tables.insertColumn | `tables.insertColumn` | Yes | `non-idempotent` | No | Yes | +| tables.deleteColumn | `tables.deleteColumn` | Yes | `conditional` | No | Yes | | tables.setColumnWidth | `tables.setColumnWidth` | Yes | `idempotent` | No | Yes | | tables.distributeColumns | `tables.distributeColumns` | Yes | `conditional` | No | Yes | | tables.insertCell | `tables.insertCell` | Yes | `non-idempotent` | No | Yes | diff --git a/apps/docs/document-api/reference/tables/insert-column.mdx b/apps/docs/document-api/reference/tables/insert-column.mdx index eb3a07ed1e..743ed88477 100644 --- a/apps/docs/document-api/reference/tables/insert-column.mdx +++ b/apps/docs/document-api/reference/tables/insert-column.mdx @@ -14,7 +14,7 @@ Insert a new column into the target table. The new column is cloned from an adja - API member path: `editor.doc.tables.insertColumn(...)` - Mutates document: `yes` - Idempotency: `non-idempotent` -- Supports tracked mode: `yes` +- Supports tracked mode: `no` - Supports dry run: `yes` - Deterministic target resolution: `yes` diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index d5f5be9de2..0ef95bfd60 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -2852,7 +2852,13 @@ export const OPERATION_DEFINITIONS = { metadata: mutationOperation({ idempotency: 'conditional', supportsDryRun: true, - supportsTrackedMode: true, + // Row-level structural deletion cannot be represented as a decidable + // tracked change in the current review model (only whole-table + // insert/delete is decidable; see structuralRowChanges.js). Reported as + // unsupported so a tracked call is rejected loudly rather than silently + // applied as a direct (untracked) deletion, which would be data loss in + // suggesting mode. + supportsTrackedMode: false, possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], throws: [...T_NOT_FOUND_COMMAND, 'INVALID_TARGET'], }), @@ -2922,7 +2928,11 @@ export const OPERATION_DEFINITIONS = { metadata: mutationOperation({ idempotency: 'non-idempotent', supportsDryRun: true, - supportsTrackedMode: true, + // Column structure changes have no structural tracked-change + // representation (OOXML tracks rows, not columns, via / in + // ). Reported as unsupported so a tracked call is rejected loudly + // rather than silently applied directly. + supportsTrackedMode: false, possibleFailureCodes: ['INVALID_TARGET'], throws: [...T_NOT_FOUND_COMMAND, 'INVALID_TARGET'], }), @@ -2939,7 +2949,11 @@ export const OPERATION_DEFINITIONS = { metadata: mutationOperation({ idempotency: 'conditional', supportsDryRun: true, - supportsTrackedMode: true, + // Column structure changes have no structural tracked-change + // representation (OOXML tracks rows, not columns). Reported as + // unsupported so a tracked call is rejected loudly rather than silently + // applied as a direct (untracked) deletion. + supportsTrackedMode: false, possibleFailureCodes: ['INVALID_TARGET', 'NO_OP'], throws: [...T_NOT_FOUND_COMMAND, 'INVALID_TARGET'], }), diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts index c7f22ff75f..3350a87092 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -12214,6 +12214,12 @@ describe('document-api adapter conformance', () => { 'tables.setRowOptions', 'tables.setColumnWidth', 'tables.distributeColumns', + // Row/column structural ops cannot produce a decidable tracked change in + // the review model (only whole-table insert/delete is decidable), so they + // report supportsTrackedMode: false and reject changeMode: 'tracked'. + 'tables.deleteRow', + 'tables.insertColumn', + 'tables.deleteColumn', 'tables.convertFromText', 'tables.split', 'tables.convertToText', @@ -12254,10 +12260,9 @@ describe('document-api adapter conformance', () => { const trackedTableOps: OperationId[] = [ 'create.table', 'tables.delete', + // tables.insertRow stamps a structural rowInsert revision that exports as + // ; the row/column structural ops do not support tracked mode. 'tables.insertRow', - 'tables.deleteRow', - 'tables.insertColumn', - 'tables.deleteColumn', ] as OperationId[]; for (const opId of trackedTableOps) { @@ -12283,28 +12288,45 @@ describe('document-api adapter conformance', () => { { changeMode: 'tracked' }, ); expect(insertRowResult.success).toBe(true); + }); - // tables.deleteRow with tracked mode - const deleteRowResult = tablesDeleteRowWrapper(editor, { nodeId: 'table-1', rowIndex: 0 } as any, { - changeMode: 'tracked', - }); - expect(deleteRowResult.success).toBe(true); + it('rejects changeMode=tracked with CAPABILITY_UNAVAILABLE for row/column structural ops', () => { + // deleteRow/insertColumn/deleteColumn cannot produce a decidable tracked + // change in the review model, so they must fail loudly rather than silently + // applying directly (which for the deletes is data loss in suggesting mode). + const editor = makeTableEditor({ insertTrackedChange: vi.fn(() => true) }); + (editor as any).options = { user: { name: 'Agent', email: 'agent@test.com' } }; + initRevision(editor); - // tables.insertColumn with tracked mode - const insertColResult = tablesInsertColumnWrapper( - editor, - { nodeId: 'table-1', columnIndex: 0, position: 'right' }, - { changeMode: 'tracked' }, - ); - expect(insertColResult.success).toBe(true); + const expectCapabilityUnavailable = (fn: () => unknown, opId: string) => { + let capturedCode: string | null = null; + try { + fn(); + } catch (error) { + capturedCode = (error as { code?: string }).code ?? null; + } + expect(capturedCode, `${opId} should throw CAPABILITY_UNAVAILABLE in tracked mode`).toBe( + 'CAPABILITY_UNAVAILABLE', + ); + }; - // tables.deleteColumn with tracked mode - const deleteColResult = tablesDeleteColumnWrapper( - editor, - { nodeId: 'table-1', columnIndex: 0 }, - { changeMode: 'tracked' }, + expectCapabilityUnavailable( + () => tablesDeleteRowWrapper(editor, { nodeId: 'table-1', rowIndex: 0 } as any, { changeMode: 'tracked' }), + 'tables.deleteRow', + ); + expectCapabilityUnavailable( + () => + tablesInsertColumnWrapper( + editor, + { nodeId: 'table-1', columnIndex: 0, position: 'right' }, + { changeMode: 'tracked' }, + ), + 'tables.insertColumn', + ); + expectCapabilityUnavailable( + () => tablesDeleteColumnWrapper(editor, { nodeId: 'table-1', columnIndex: 0 }, { changeMode: 'tracked' }), + 'tables.deleteColumn', ); - expect(deleteColResult.success).toBe(true); }); // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.convenience.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.convenience.test.ts index 5406a5b224..1f2c415e5a 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.convenience.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.convenience.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeAll, describe, expect, it } from 'vitest'; import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; +import { translator as trTranslator } from '../core/super-converter/v3/handlers/w/tr/tr-translator.js'; import type { Editor } from '../core/Editor.js'; import { createTableAdapter, @@ -14,7 +15,9 @@ import { tablesApplyBorderPresetAdapter, tablesSetShadingAdapter, tablesInsertRowAdapter, + tablesDeleteRowAdapter, tablesInsertColumnAdapter, + tablesDeleteColumnAdapter, tablesGetCellsAdapter, tablesSetCellTextAdapter, tablesApplyPresetAdapter, @@ -38,13 +41,14 @@ describe('SD-2129: table convenience operations', () => { editor = undefined; }); - function createEditor(): Editor { + function createEditor(opts?: { user?: { name: string; email: string } }): Editor { const result = initTestEditor({ content: docData.docx, media: docData.media, mediaFiles: docData.mediaFiles, fonts: docData.fonts, useImmediateSetTimeout: false, + ...(opts?.user ? { user: opts.user, trackedChanges: {} } : {}), }); editor = result.editor; return editor; @@ -676,6 +680,162 @@ describe('SD-2129: table convenience operations', () => { }); }); + // --------------------------------------------------------------------------- + // Tracked-mode operations + // --------------------------------------------------------------------------- + + describe('tracked mode', () => { + const TRACKED = { changeMode: 'tracked' as const }; + const TEST_USER = { name: 'Tester', email: 'tester@example.com' }; + + /** Collect the trackChange attrs of all rowInsert-stamped rows in the doc. */ + const collectStampedRows = (ed: Editor) => { + const out: Array<{ node: any; tc: any }> = []; + ed.state.doc.descendants((node: any) => { + if (node.type.name !== 'tableRow') return; + const tc = node.attrs?.trackChange; + if (tc && tc.type === 'rowInsert') out.push({ node, tc }); + }); + return out; + }; + + const expectCapabilityUnavailable = (fn: () => unknown, opId: string) => { + let code: string | null = null; + try { + fn(); + } catch (error) { + code = (error as { code?: string }).code ?? null; + } + expect(code, `${opId} should throw CAPABILITY_UNAVAILABLE in tracked mode`).toBe('CAPABILITY_UNAVAILABLE'); + }; + + it('tables.insertRow with tracked mode stamps rowInsert revision on the new row', () => { + const ed = createEditor({ user: TEST_USER }); + // Enable tracked changes so the dispatch pipeline routes through + // trackedTransaction; the adapter stamps the row revision directly. + ed.commands.enableTrackChanges(); + + const tableId = createTableAndGetId(ed); // 3 rows × 3 columns + + // Insert a row in tracked mode — below row 0. + const result = tablesInsertRowAdapter(ed, { nodeId: tableId, rowIndex: 0, position: 'below' }, TRACKED); + expect(result.success).toBe(true); + + // Exactly one row carries a rowInsert revision — the newly inserted row + // at index 1. Its shape mirrors the OOXML importer (row-track-change.js). + const stamped = collectStampedRows(ed); + expect(stamped.length).toBe(1); + const { tc } = stamped[0]!; + expect(tc.author).toBe(TEST_USER.name); + expect(tc.authorEmail).toBe(TEST_USER.email); + expect(typeof tc.id).toBe('string'); + expect(typeof tc.revisionGroupId).toBe('string'); + expect(typeof tc.date).toBe('string'); + + // The table should now have 4 rows. + const cells = tablesGetCellsAdapter(ed, { nodeId: requireTableNodeId(result, 'insertRow') }); + const rowCount = new Set(cells.cells.map((c) => c.rowIndex)).size; + expect(rowCount).toBe(4); + }); + + it('tables.insertRow tracked mode exports the new row as inside ', () => { + // This is the user-visible payoff of tracked mode: the inserted row + // round-trips to OOXML as a tracked insertion that Word can accept/reject. + const ed = createEditor({ user: TEST_USER }); + ed.commands.enableTrackChanges(); + const tableId = createTableAndGetId(ed); + + tablesInsertRowAdapter(ed, { nodeId: tableId, rowIndex: 0, position: 'below' }, TRACKED); + + const stamped = collectStampedRows(ed); + expect(stamped.length).toBe(1); + + const decoded = (trTranslator as any).decode({ node: stamped[0]!.node.toJSON() }, {}); + const trPr = decoded.elements?.find((el: any) => el.name === 'w:trPr'); + expect(trPr).toBeDefined(); + const ins = trPr.elements?.find((el: any) => el.name === 'w:ins'); + expect(ins).toBeDefined(); + expect(ins.attributes['w:author']).toBe(TEST_USER.name); + }); + + it('tables.insertRow with tracked mode shares one revisionGroupId across a multi-row insert', () => { + const ed = createEditor({ user: TEST_USER }); + ed.commands.enableTrackChanges(); + + const tableId = createTableAndGetId(ed); // 3 rows + + const result = tablesInsertRowAdapter(ed, { nodeId: tableId, count: 2, rowIndex: 0, position: 'below' }, TRACKED); + expect(result.success).toBe(true); + + const stamped = collectStampedRows(ed); + expect(stamped.length).toBe(2); + // Word assigns a distinct w:id per row, but rows inserted in one call form + // one logical change and therefore share a single revisionGroupId. + expect(new Set(stamped.map((r) => r.tc.id)).size).toBe(2); + expect(new Set(stamped.map((r) => r.tc.revisionGroupId)).size).toBe(1); + + // Table should now have 5 rows. + const cells = tablesGetCellsAdapter(ed, { nodeId: requireTableNodeId(result, 'insertRow') }); + const rowCount = new Set(cells.cells.map((c) => c.rowIndex)).size; + expect(rowCount).toBe(5); + }); + + it('tables.insertRow with direct mode does NOT stamp trackChange', () => { + const ed = createEditor({ user: TEST_USER }); + ed.commands.enableTrackChanges(); // tracking is ON, but changeMode is direct + + const tableId = createTableAndGetId(ed); // 3 rows + + const result = tablesInsertRowAdapter(ed, { nodeId: tableId, rowIndex: 0, position: 'below' }, DIRECT); + expect(result.success).toBe(true); + + // No row should have trackChange — direct mode bypasses stamping. + expect(collectStampedRows(ed).length).toBe(0); + }); + + it('tables.insertRow append-at-end shorthand in tracked mode stamps rowInsert', () => { + const ed = createEditor({ user: TEST_USER }); + ed.commands.enableTrackChanges(); + + const tableId = createTableAndGetId(ed); // 3 rows + + // Append-at-end shorthand: table-level target, no rowIndex/position. + const result = tablesInsertRowAdapter(ed, { nodeId: tableId }, TRACKED); + expect(result.success).toBe(true); + + expect(collectStampedRows(ed).length).toBe(1); + + const cells = tablesGetCellsAdapter(ed, { nodeId: requireTableNodeId(result, 'insertRow') }); + const rowCount = new Set(cells.cells.map((c) => c.rowIndex)).size; + expect(rowCount).toBe(4); + }); + + it('row/column structural ops reject tracked mode with CAPABILITY_UNAVAILABLE', () => { + // These ops cannot produce a decidable tracked change, so they must fail + // loudly rather than silently apply directly (data loss on the deletes). + const ed = createEditor({ user: TEST_USER }); + ed.commands.enableTrackChanges(); + const tableId = createTableAndGetId(ed); // 3 rows × 3 columns + + expectCapabilityUnavailable( + () => tablesDeleteRowAdapter(ed, { nodeId: tableId, rowIndex: 1 } as any, TRACKED), + 'tables.deleteRow', + ); + expectCapabilityUnavailable( + () => tablesInsertColumnAdapter(ed, { nodeId: tableId, columnIndex: 0, position: 'right' } as any, TRACKED), + 'tables.insertColumn', + ); + expectCapabilityUnavailable( + () => tablesDeleteColumnAdapter(ed, { nodeId: tableId, columnIndex: 1 } as any, TRACKED), + 'tables.deleteColumn', + ); + + // The table is untouched — no partial mutation leaked before the throw. + const cells = tablesGetCellsAdapter(ed, { nodeId: tableId }); + expect(new Set(cells.cells.map((c) => c.rowIndex)).size).toBe(3); + }); + }); + // --------------------------------------------------------------------------- // SD-2540 round 3 — set_cell_text + apply_preset + set_style_options // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.ts index 875b76c7f5..9b16775c75 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/tables-adapter.ts @@ -922,6 +922,75 @@ function insertRowInTable( return true; } +/** + * Stamp a structural `rowInsert` revision on a single table row that was + * inserted via the document API's `tables.insertRow` adapter. + * + * The tracked-transaction pipeline (inline overlap compiler) cannot represent + * raw `tableRow` insert steps, so this structural revision is stamped directly + * on the row's `trackChange` attribute — the same shape the importer lands from + * `` inside `` (see `super-converter/v3/handlers/w/tr/row-track-change.js`, + * and the canonical whole-table authoring path in + * `extensions/track-changes/trackChangesHelpers/stampTableRows.js`). + * + * Scope note: the stamp makes the row export as a tracked insertion (``), + * which Word can accept/reject. It is NOT, however, a decidable in-app + * structural change: SuperDoc's review model only treats a WHOLE-table + * insert/delete as decidable (`structuralRowChanges.js`); a single tracked row + * inside an otherwise-untracked table is surfaced as `partial-rows` / + * `decidable: false`. All rows of one `insertRow` call share `revision` so they + * read as one logical change if/when row-level decidability is added. + * + * @param tr - The transaction containing the inserted row. + * @param tablePos - Absolute position of the table node. + * @param rowIndex - Index of the newly inserted row within the table. + * @param editor - The editor instance (used for user attribution). + * @param revision - Shared revision identity (groupId + ISO date) for the call. + */ +function stampInsertedRowTrackChange( + tr: Transaction, + tablePos: number, + rowIndex: number, + editor: Editor, + revision: { revisionGroupId: string; date: string }, +): void { + const user = editor.options?.user; + if (!user) return; + + const tableNode = tr.doc.nodeAt(tablePos); + if (!tableNode || tableNode.type.name !== 'table') return; + + // Walk the table children to find the absolute position of the target row. + let rowPos = tablePos + 1; + const maxRow = Math.min(rowIndex, tableNode.childCount - 1); + for (let r = 0; r < maxRow; r++) { + rowPos += tableNode.child(r).nodeSize; + } + + const rowNode = tr.doc.nodeAt(rowPos); + if (!rowNode || rowNode.type.name !== 'tableRow') return; + // Don't clobber an existing structural revision. + if ((rowNode.attrs as Record)?.trackChange) return; + + const trackChange = { + type: 'rowInsert' as const, + // Word assigns a distinct w:id per row; the shared revisionGroupId groups + // them as one logical change. + id: uuidv4(), + author: user.name || '', + authorId: user.id || '', + authorEmail: user.email || '', + authorImage: user.image || '', + date: revision.date, + revisionGroupId: revision.revisionGroupId, + }; + + tr.setNodeMarkup(rowPos, undefined, { + ...(rowNode.attrs as Record), + trackChange, + }); +} + function addColumnToTableForSplit( tr: Transaction, tablePos: number, @@ -1368,6 +1437,10 @@ export function tablesInsertRowAdapter( const position = normalizedAny.position ?? 'below'; const schema = editor.state.schema; + // One shared revision identity for all rows inserted by this call, so the + // structural enumerator can group them as a single logical change. + const revision = mode === 'tracked' ? { revisionGroupId: uuidv4(), date: new Date().toISOString() } : null; + for (let i = 0; i < count; i++) { // Re-read the table from the (possibly modified) transaction const currentTableNode = tr.doc.nodeAt(tablePos); @@ -1386,10 +1459,18 @@ export function tablesInsertRowAdapter( if (!didInsertRow) { return toTableFailure('INVALID_TARGET', 'Row insertion could not be applied.'); } + + // In tracked mode, stamp the inserted row with a structural revision + // (rowInsert) so it exports as a tracked insertion (``). We stamp + // directly rather than relying on the trackedTransaction pipeline because + // the inline overlap compiler cannot represent tableRow insert steps. See + // stampInsertedRowTrackChange for the in-app decidability caveat. + if (revision) { + stampInsertedRowTrackChange(tr, tablePos, insertIdx, editor, revision); + } } - if (mode === 'tracked') applyTrackedMutationMeta(tr); - else applyDirectMutationMeta(tr); + applyDirectMutationMeta(tr); editor.dispatch(tr); clearIndexCache(editor); return buildTableSuccess(resolvePostMutationTableAddress(editor, table.candidate.pos, table.address.nodeId, tr)); @@ -1407,10 +1488,10 @@ export function tablesDeleteRowAdapter( input: TablesDeleteRowInput, options?: MutationOptions, ): TableMutationResult { - const mode = options?.changeMode ?? 'direct'; - if (mode === 'tracked') { - ensureTrackedCapability(editor, { operation: 'tables.deleteRow' }); - } + // Row-level structural deletion has no decidable tracked-change + // representation in the review model; reject tracked mode rather than + // silently deleting the row untracked (data loss in suggesting mode). + rejectTrackedMode('tables.deleteRow', options); const resolved = resolveRowLocator(editor, input as RowLocatorFields, 'tables.deleteRow'); const { table, rowIndex, rowNode, rowPos } = resolved; @@ -1461,8 +1542,7 @@ export function tablesDeleteRowAdapter( } } - if (mode === 'tracked') applyTrackedMutationMeta(tr); - else applyDirectMutationMeta(tr); + applyDirectMutationMeta(tr); editor.dispatch(tr); clearIndexCache(editor); return buildTableSuccess(resolvePostMutationTableAddress(editor, table.candidate.pos, table.address.nodeId, tr)); @@ -1647,10 +1727,10 @@ export function tablesInsertColumnAdapter( input: TablesInsertColumnInput, options?: MutationOptions, ): TableMutationResult { - const mode = options?.changeMode ?? 'direct'; - if (mode === 'tracked') { - ensureTrackedCapability(editor, { operation: 'tables.insertColumn' }); - } + // Column structure changes have no tracked-change representation (OOXML + // tracks rows, not columns); reject tracked mode rather than silently + // applying the column insert untracked. + rejectTrackedMode('tables.insertColumn', options); // Resolve shorthand to a concrete columnIndex + left/right before // delegating to the column locator (which requires columnIndex). Two cases: @@ -1709,8 +1789,7 @@ export function tablesInsertColumnAdapter( } } - if (mode === 'tracked') applyTrackedMutationMeta(tr); - else applyDirectMutationMeta(tr); + applyDirectMutationMeta(tr); editor.dispatch(tr); clearIndexCache(editor); return buildTableSuccess(resolvePostMutationTableAddress(editor, table.candidate.pos, table.address.nodeId, tr)); @@ -1728,10 +1807,10 @@ export function tablesDeleteColumnAdapter( input: TablesDeleteColumnInput, options?: MutationOptions, ): TableMutationResult { - const mode = options?.changeMode ?? 'direct'; - if (mode === 'tracked') { - ensureTrackedCapability(editor, { operation: 'tables.deleteColumn' }); - } + // Column structure changes have no tracked-change representation (OOXML + // tracks rows, not columns); reject tracked mode rather than silently + // deleting the column untracked (data loss in suggesting mode). + rejectTrackedMode('tables.deleteColumn', options); const resolved = resolveColumnLocator(editor, input, 'tables.deleteColumn'); const { table, columnIndex, columnCount } = resolved; @@ -1761,8 +1840,7 @@ export function tablesDeleteColumnAdapter( } } - if (mode === 'tracked') applyTrackedMutationMeta(tr); - else applyDirectMutationMeta(tr); + applyDirectMutationMeta(tr); editor.dispatch(tr); clearIndexCache(editor); return buildTableSuccess(resolvePostMutationTableAddress(editor, table.candidate.pos, table.address.nodeId, tr));