diff --git a/packages/layout-engine/contracts/src/index.test.ts b/packages/layout-engine/contracts/src/index.test.ts index 51286c7172..9496677ac9 100644 --- a/packages/layout-engine/contracts/src/index.test.ts +++ b/packages/layout-engine/contracts/src/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { cloneColumnLayout, extractHeaderFooterSpace, normalizeColumnLayout, widthsEqual } from './index.js'; -import type { FlowBlock, Layout } from './index.js'; +import type { FlowBlock, Layout, TableRow, TrackedChangeMeta } from './index.js'; describe('contracts', () => { it('accepts a basic FlowBlock structure', () => { @@ -85,4 +85,18 @@ describe('contracts', () => { }); expect(normalizeColumnLayout({ count: 2, gap: 24 }, 624).widths).toEqual([300, 300]); }); + + it('TrackedChangeMeta accepts an optional operationId', () => { + const meta: TrackedChangeMeta = { kind: 'delete', id: 'r1', operationId: 'op-table-1' }; + expect(meta.operationId).toBe('op-table-1'); + }); + + it('TableRow accepts an optional trackedChange field carrying row-level metadata', () => { + const row: TableRow = { + id: 'row-1', + cells: [], + trackedChange: { kind: 'insert', id: 'r1', operationId: 'op-table-1' }, + }; + expect(row.trackedChange?.kind).toBe('insert'); + }); }); diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index d5ece58c9d..46e8275132 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -366,6 +366,12 @@ export type TrackedChangeMeta = { id: string; overlapParentId?: string; relationship?: 'parent' | 'child' | 'standalone'; + /** + * Optional grouping key. Tracked changes sharing a non-empty `operationId` + * are resolved together — e.g. every row of a deleted table shares one + * `operationId` and the review surface collapses them into one entry. + */ + operationId?: string; /** * Internal story key identifying which content story owns this tracked * change (`'body'`, `'hf:part:…'`, `'fn:…'`, `'en:…'`). @@ -926,6 +932,12 @@ export type TableRow = { cells: TableCell[]; attrs?: TableRowAttrs; sourceAnchor?: SourceAnchor; + /** + * Row-level tracked-change metadata. Populated by pm-adapter from the + * ProseMirror `trackChange` node attribute. The painter reads this and + * stamps `data-track-change*` on the cell DOM elements. + */ + trackedChange?: TrackedChangeMeta; }; export type TableBlock = { diff --git a/packages/layout-engine/layout-bridge/src/cache.ts b/packages/layout-engine/layout-bridge/src/cache.ts index fa3d1a515d..8b990ded4a 100644 --- a/packages/layout-engine/layout-bridge/src/cache.ts +++ b/packages/layout-engine/layout-bridge/src/cache.ts @@ -306,6 +306,21 @@ const hashRuns = (block: FlowBlock): string => { continue; } + // Row-level tracked change (block-level diff replay sets `trackedChange` + // on a tableRow's attrs). Without folding it into the measure-cache + // fingerprint, applyHunks-style transactions that only mutate the row's + // `trackChange` attr don't invalidate the cache, so the page is never + // re-measured and the visible cells never receive their decoration + // classes. Read from `row.attrs.trackedChange` — the canonical location + // written by the v1 layout-adapter from the OOXML-aligned + // `attrs.trackChange.type` shape. Mirror of the painter page-fingerprint + // fix in renderer.ts and the canonical-version fix in + // layout-resolved/versionSignature.ts. + const rowTC = row.attrs?.trackedChange; + if (rowTC) { + cellHashes.push(`rtc:${rowTC.kind ?? ''}:${rowTC.id ?? ''}:${rowTC.operationId ?? ''}`); + } + for (const cell of row.cells) { // Include cell-level attributes that affect rendering (borders, padding, etc.) // This ensures cache invalidation when cell formatting changes (e.g., remove borders). diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 672a866dc2..0b76046c96 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -533,6 +533,20 @@ export const deriveBlockVersion = (block: FlowBlock): string => { for (const row of rows) { if (!row || !Array.isArray(row.cells)) continue; hash = hashNumber(hash, row.cells.length); + // Row-level tracked change (block-level diff replay sets `trackedChange` + // on a tableRow's attrs). Without folding it into the canonical block + // version, applyHunks-style transactions that only mutate the row's + // `trackChange` attr don't bump the version, so the resolved-layout + // pipeline reuses cached entries and the painter never sees the update. + // Read from `row.attrs.trackedChange` — the canonical location written + // by the v1 layout-adapter from the OOXML-aligned `attrs.trackChange.type` + // shape. Mirror of the fixes in renderer.ts and layout-bridge/cache.ts. + const rowTC = row.attrs?.trackedChange; + if (rowTC) { + hash = hashString(hash, rowTC.kind ?? ''); + hash = hashString(hash, rowTC.id ?? ''); + hash = hashString(hash, rowTC.operationId ?? ''); + } for (const cell of row.cells) { if (!cell) continue; const cellBlocks = cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []); diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts index 2a4b99b983..bfc2792169 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts @@ -5,6 +5,7 @@ import type { ParagraphMeasure, ResolvedFragmentItem, SdtMetadata, + TrackedChangeMeta, } from '@superdoc/contracts'; import { isMinimalWordLayout as isMinimalWordLayoutShared } from '@superdoc/common/list-marker-utils'; import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; @@ -13,6 +14,7 @@ import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; import { CLASS_NAMES, fragmentStyles } from '../styles.js'; import { shouldRenderSdtContainerChrome, type SdtBoundaryOptions } from '../sdt/container.js'; import { allowFontSynthesis } from '../runs/font-synthesis.js'; +import { applyBlockTrackedChangeToParagraph, resolveTrackedChangesConfig } from '../runs/tracked-changes.js'; import type { BetweenBorderInfo } from './borders/index.js'; import { renderParagraphContent, type ParagraphRenderLineInput } from './renderParagraphContent.js'; @@ -152,6 +154,16 @@ export const renderParagraphFragment = (params: RenderParagraphFragmentParams): contentControlsChrome, }); + // Whole-paragraph structural tracked change (insert/delete). The adapter + // stamped paint-ready meta onto block.attrs.trackedChange; decorate the + // fragment so a deleted paragraph strikes through (and collapses in 'final' + // mode) instead of leaving an empty bullet — the paragraph analogue of the + // row-cell decoration. + const blockTrackedChange = (block.attrs as { trackedChange?: TrackedChangeMeta } | undefined)?.trackedChange; + if (blockTrackedChange) { + applyBlockTrackedChangeToParagraph(fragmentEl, blockTrackedChange, resolveTrackedChangesConfig(block)); + } + return fragmentEl; } catch (error) { console.error('[DomPainter] Fragment rendering failed:', { fragment, error }); diff --git a/packages/layout-engine/painters/dom/src/runs/tracked-changes.ts b/packages/layout-engine/painters/dom/src/runs/tracked-changes.ts index 45fb421098..2aa2555e10 100644 --- a/packages/layout-engine/painters/dom/src/runs/tracked-changes.ts +++ b/packages/layout-engine/painters/dom/src/runs/tracked-changes.ts @@ -203,6 +203,62 @@ export const applyRowTrackedChangeToCell = ( } }; +/** + * Block-context marker for a whole-paragraph structural tracked change. Pairs + * with the same base/modifier classes the inline + row paths use; the block + * CSS targets it without colliding with the inline `.track-*-dec` span rules. + */ +const TRACK_CHANGE_BLOCK_CLASS = 'track-block-dec'; + +/** + * Applies a structural block-level tracked change (inserted/deleted whole + * paragraph) to its paragraph fragment element. The paragraph analogue of + * {@link applyRowTrackedChangeToCell}: same `TrackedChangeMeta`, base classes + * (`track-insert-dec` / `track-delete-dec`), mode modifier map, and per-author + * color variables. `hidden` mode collapses the paragraph (insert in 'original', + * delete in 'final') via the shared `.track-*-dec.hidden { display: none }` + * rule, matching inline + row behavior. + */ +export const applyBlockTrackedChangeToParagraph = ( + elem: HTMLElement, + meta: TrackedChangeMeta, + config: TrackedChangesRenderConfig, +): void => { + if (!config.enabled || config.mode === 'off') { + return; + } + if (meta.kind !== 'insert' && meta.kind !== 'delete') { + return; + } + + const baseClass = TRACK_CHANGE_BASE_CLASS[meta.kind]; + if (baseClass) { + elem.classList.add(baseClass); + } + elem.classList.add(TRACK_CHANGE_BLOCK_CLASS); + + const modifier = TRACK_CHANGE_MODIFIER_CLASS[meta.kind]?.[config.mode]; + if (modifier) { + elem.classList.add(modifier); + } + + applyAuthorColorVariables(elem, meta); + + elem.dataset.trackChangeId = meta.id; + elem.dataset.trackChangeKind = meta.kind; + elem.dataset.trackChangeStructural = 'block'; + elem.dataset.storyKey = meta.storyKey ?? 'body'; + if (meta.author) { + elem.dataset.trackChangeAuthor = meta.author; + } + if (meta.authorEmail) { + elem.dataset.trackChangeAuthorEmail = meta.authorEmail; + } + if (meta.date) { + elem.dataset.trackChangeDate = meta.date; + } +}; + export const applyTrackedChangeDecorations = ( elem: HTMLElement, run: Run, diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 7a7c8c71c0..c89378a653 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -484,4 +484,23 @@ describe('ensureTrackChangeStyles', () => { ); expect(cssText).not.toMatch(/track-format-dec\.highlighted\.track-change-focused\s*\{[\s\S]*border-bottom-width:/); }); + + it('block-level (row-cell) tracked-change rules are scoped to .superdoc-layout, not bare selectors', () => { + ensureTrackChangeStyles(document); + + const styleEl = document.querySelector('[data-superdoc-track-change-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + + // Scoped row-cell selectors present (upstream replaced the previous + // `[data-track-change='...']` attribute selectors with class-based + // `.track-row-cell-dec.track-insert-dec.highlighted` etc. that + // `applyRowTrackedChangeToCell` adds). + expect(cssText).toContain('.superdoc-layout .track-row-cell-dec.track-insert-dec.highlighted'); + expect(cssText).toContain('.superdoc-layout .track-row-cell-dec.track-delete-dec.highlighted'); + + // No bare `.track-row-cell-dec { ... }` selector — would leak the + // row-cell decoration outside the painted layout (e.g. into the PM + // contenteditable mirror or any host page using the same class name). + expect(cssText).not.toMatch(/^\s*\.track-row-cell-dec[.\s{]/m); + }); }); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 88adb80858..e1a0ec8bd3 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -440,6 +440,31 @@ const TRACK_CHANGE_STYLES = ` var(--sd-tracked-changes-delete-text, #cb0e47) var(--sd-tracked-changes-delete-decoration-thickness, 2px); } + +/* + * Block-level (whole-paragraph) structural tracked changes. The paragraph + * analogue of the row-cell rules above: the paragraph fragment carries the same + * base class (track-insert-dec / track-delete-dec) + modifier (highlighted / + * hidden) plus the block-context marker class track-block-dec. 'hidden' mode + * collapses the paragraph via the shared .track-*-dec.hidden { display: none } + * rule, so an inserted paragraph in 'original' mode and a deleted paragraph in + * 'final' mode disappear — matching inline + row behavior. + */ +.superdoc-layout .track-block-dec.track-insert-dec.highlighted { + background-color: var(--sd-tracked-changes-insert-background, #399c7222); +} + +.superdoc-layout .track-block-dec.track-delete-dec.highlighted { + background-color: var(--sd-tracked-changes-delete-background, #cb0e4722); +} + +.superdoc-layout .track-block-dec.track-delete-dec.highlighted .superdoc-line { + text-decoration: + line-through + solid + var(--sd-tracked-changes-delete-text, #cb0e47) + var(--sd-tracked-changes-delete-decoration-thickness, 2px); +} `; const FORMATTING_MARKS_STYLES = ` diff --git a/packages/super-editor/src/editors/v1/assets/styles/elements/prosemirror.css b/packages/super-editor/src/editors/v1/assets/styles/elements/prosemirror.css index 7fd224b92c..cfa01e613c 100644 --- a/packages/super-editor/src/editors/v1/assets/styles/elements/prosemirror.css +++ b/packages/super-editor/src/editors/v1/assets/styles/elements/prosemirror.css @@ -306,6 +306,34 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html } /* Track changes - end */ +/* + * Block-level tracked changes (row granularity for v1) — contenteditable path. + * + * The data-track-change attribute is stamped on PM's
hi |
after
', + extensions: [...getStarterExtensions(), StructuralTrackChanges], + })); + }); + afterEach(() => editor?.destroy()); + + it('setStructuralDiff stamps every row of the targeted table as rowDelete via stampTableRows', () => { + const table = editor.state.doc.firstChild; + const ok = editor.commands.setStructuralDiff([ + { kind: 'remove', changeId: 't1', basePos: 0, baseNodeSize: table.nodeSize }, + ]); + expect(ok).toBe(true); + const changes = enumerateStructuralRowChanges(editor.state); + expect(changes).toHaveLength(1); + expect(changes[0].subtype).toBe('table-delete'); + expect(changes[0].wholeTable).toBe(true); + }); + + it('is registered in default starter extensions', () => { + const names = getStarterExtensions().map((e) => e.name); + expect(names).toContain('structuralTrackChanges'); + }); + + it('acceptAllTrackedChanges resolves a staged table deletion (table + shell gone)', () => { + // Consumers like al-pmo only call acceptAllTrackedChanges; routing through + // the review-model decision engine (via the structural-row review-graph + // projection) handles whole-table accept without any block-level fallback. + const table = editor.state.doc.firstChild; + editor.commands.setStructuralDiff([{ kind: 'remove', changeId: 't1', basePos: 0, baseNodeSize: table.nodeSize }]); + editor.commands.acceptAllTrackedChanges(); + let hasTable = false; + editor.state.doc.descendants((n) => { + if (n.type.name === 'table') hasTable = true; + }); + expect(hasTable).toBe(false); + expect(enumerateStructuralRowChanges(editor.state)).toHaveLength(0); + }); + + it('rejectAllTrackedChanges clears the structural trackChange attrs and keeps the table', () => { + const table = editor.state.doc.firstChild; + editor.commands.setStructuralDiff([{ kind: 'remove', changeId: 't1', basePos: 0, baseNodeSize: table.nodeSize }]); + editor.commands.rejectAllTrackedChanges(); + let hasTable = false; + editor.state.doc.descendants((n) => { + if (n.type.name === 'table') hasTable = true; + }); + expect(hasTable).toBe(true); + expect(enumerateStructuralRowChanges(editor.state)).toHaveLength(0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/applyHunks.js b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/applyHunks.js new file mode 100644 index 0000000000..7ae5fd0d02 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/applyHunks.js @@ -0,0 +1,94 @@ +// @ts-check +import { stampTableRows } from '../../track-changes/trackChangesHelpers/stampTableRows.js'; + +/** + * @typedef {{ + * kind: 'remove' | 'insert', + * changeId: string, + * basePos?: number, + * anchorBasePos?: number, + * proposalNode?: import('prosemirror-model').Node, + * }} StructuralHunk + */ + +/** + * Apply structural diff hunks to a transaction as upstream-native row-level + * tracked changes. + * + * For each hunk: + * remove → stamp the existing table at `basePos` with `rowDelete` (the + * content stays visible, struck-through, until accept/reject). + * insert → insert the proposal table at `anchorBasePos`, then stamp it with + * `rowInsert`. + * + * Delegates to `stampTableRows()` so every row of a tracked table carries the + * same attribute shape that the OOXML importer/exporter, the row-change + * enumerator, the review graph, and the decision engine already understand + * (`{ type: 'rowInsert' | 'rowDelete', id, author, authorId, authorEmail, + * authorImage, date, revisionGroupId }`). One `revisionGroupId` per table. + * + * Tables only. Paragraph-level structural revisions use a different OOXML + * primitive (`w:rPr/w:ins`) and would belong in a separate change. + * + * @param {{ + * tr: import('prosemirror-state').Transaction, + * state: import('prosemirror-state').EditorState, + * user: import('../../../core/types/EditorConfig.js').User, + * date: string, + * hunks: StructuralHunk[], + * }} args + * @returns {{ applied: number, warnings: string[] }} + */ + +export const applyHunks = ({ tr, state, user, date, hunks }) => { + /** @type {string[]} */ + const warnings = []; + let applied = 0; + + for (const hunk of hunks) { + if (!hunk || !hunk.changeId) continue; + + if (hunk.kind === 'remove') { + if (typeof hunk.basePos !== 'number') { + warnings.push(`Missing basePos for remove hunk ${hunk.changeId}`); + continue; + } + const livePos = tr.mapping.map(hunk.basePos); + const node = tr.doc.nodeAt(livePos); + if (!node || node.type.name !== 'table') { + warnings.push(`Expected a table at pos ${hunk.basePos} for remove hunk ${hunk.changeId}`); + continue; + } + if (stampTableRows({ type: 'rowDelete', tr, from: livePos, to: livePos + node.nodeSize, user, date })) { + applied += 1; + } + continue; + } + + if (hunk.kind === 'insert') { + if (!hunk.proposalNode) { + warnings.push(`Missing proposalNode for insert hunk ${hunk.changeId}`); + continue; + } + if (typeof hunk.anchorBasePos !== 'number') { + warnings.push(`Missing anchorBasePos for insert hunk ${hunk.changeId}`); + continue; + } + if (hunk.proposalNode.type.name !== 'table') { + warnings.push(`Only table inserts are supported (hunk ${hunk.changeId})`); + continue; + } + const livePos = tr.mapping.map(hunk.anchorBasePos); + const insertedSize = hunk.proposalNode.nodeSize; + tr.insert(livePos, hunk.proposalNode); + if (stampTableRows({ type: 'rowInsert', tr, from: livePos, to: livePos + insertedSize, user, date })) { + applied += 1; + } + continue; + } + + warnings.push(`Unsupported hunk kind: ${hunk.kind}`); + } + + return { applied, warnings }; +}; diff --git a/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/applyHunks.test.js b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/applyHunks.test.js new file mode 100644 index 0000000000..efcdd2ee90 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/applyHunks.test.js @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { EditorState } from 'prosemirror-state'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { applyHunks } from './applyHunks.js'; + +describe('applyHunks', () => { + let editor, schema; + const user = { name: 'Tester', email: 'test@example.com' }; + const date = '2026-06-16T00:00:00.000Z'; + beforeEach(() => { + ({ editor } = initTestEditor({ mode: 'text', content: '' })); + schema = editor.schema; + }); + afterEach(() => editor?.destroy()); + + const buildTable = (id, rowCount = 2) => { + const rows = []; + for (let i = 0; i < rowCount; i += 1) { + const cell = schema.nodes.tableCell.create(null, schema.nodes.paragraph.create(null, schema.text(`r${i}`))); + rows.push(schema.nodes.tableRow.create(null, [cell])); + } + return schema.nodes.table.create({ sdBlockId: id }, rows); + }; + + it('stamps rowDelete on every row of an existing table for a remove hunk', () => { + const table = buildTable('tbl-del', 3); + const doc = schema.nodes.doc.create(null, [table]); + const state = EditorState.create({ schema, doc }); + const tr = state.tr; + const result = applyHunks({ + tr, + state, + user, + date, + hunks: [{ kind: 'remove', changeId: 'tbl-del', basePos: 0 }], + }); + expect(result.applied).toBe(1); + const nextDoc = state.apply(tr).doc; + const next = nextDoc.firstChild; + expect(next.type.name).toBe('table'); + expect(next.childCount).toBe(3); + const groupIds = new Set(); + next.forEach((row) => { + expect(row.attrs.trackChange?.type).toBe('rowDelete'); + groupIds.add(row.attrs.trackChange?.revisionGroupId); + }); + expect(groupIds.size).toBe(1); + }); + + it('inserts a proposal table with rowInsert on every row at the anchor position', () => { + const proposalTable = buildTable('tbl-add', 2); + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, schema.text('before'))]); + const state = EditorState.create({ schema, doc }); + const tr = state.tr; + const insertPos = doc.content.size; + const result = applyHunks({ + tr, + state, + user, + date, + hunks: [{ kind: 'insert', changeId: 'tbl-add', proposalNode: proposalTable, anchorBasePos: insertPos }], + }); + expect(result.applied).toBe(1); + const nextDoc = state.apply(tr).doc; + const insertedTable = nextDoc.child(nextDoc.childCount - 1); + expect(insertedTable.type.name).toBe('table'); + insertedTable.forEach((row) => { + expect(row.attrs.trackChange?.type).toBe('rowInsert'); + expect(row.attrs.trackChange?.author).toBe(user.name); + expect(row.attrs.trackChange?.date).toBe(date); + }); + }); + + it('rows of one tracked table share a revisionGroupId but have unique row ids', () => { + const table = buildTable('tbl-ids', 4); + const doc = schema.nodes.doc.create(null, [table]); + const state = EditorState.create({ schema, doc }); + const tr = state.tr; + applyHunks({ + tr, + state, + user, + date, + hunks: [{ kind: 'remove', changeId: 'tbl-ids', basePos: 0 }], + }); + const ids = new Set(); + let revisionGroupId; + state.apply(tr).doc.firstChild.forEach((row) => { + ids.add(row.attrs.trackChange.id); + revisionGroupId = row.attrs.trackChange.revisionGroupId; + }); + expect(ids.size).toBe(4); + expect(typeof revisionGroupId).toBe('string'); + expect(revisionGroupId.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/computeStructuralDiff.js b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/computeStructuralDiff.js new file mode 100644 index 0000000000..c6b9b7ca5f --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/computeStructuralDiff.js @@ -0,0 +1,121 @@ +// @ts-check +/** + * Compute structural hunks describing whole-block add/removes between two docs. + * + * Walks both docs at depth 1, matches blocks by `identityKey` (default: a + * content fingerprint built from the block's normalized `textContent` and + * node type). For each base block absent from proposal: emit + * `{ kind: 'remove' }`. For each proposal block (of allowed type) absent from + * base: emit `{ kind: 'insert' }`. + * + * Why content fingerprint instead of sdBlockId: the docx importer assigns a + * fresh sdBlockId on every load — there's no standard OOXML attribute for + * tables to persist a stable id. So two independently-loaded copies of the + * same document have different ids; matching by id would flag every block + * as changed. Content fingerprinting matches identical blocks across + * imports. + * + * Limitations of the default fingerprint: + * - Two truly-identical blocks (e.g. empty placeholder tables) share a + * fingerprint; the algorithm treats them as one. Pass a custom + * `identityKey` if your domain has identical blocks that must be + * distinguished. + * - A block whose content changed (same structure, different text) is + * treated as a remove of the old + insert of the new — visually rendered + * as "old red + new green." Consumers wanting "table modified in-place" + * semantics should pass a structural fingerprint (e.g., row count). + * + * Consumers whose proposal is a true in-place edit (sdBlockIds preserved + * across base/proposal) can opt back into id-based matching: + * computeStructuralDiff(base, proposal, { + * identityKey: (n) => n.attrs?.sdBlockId ?? null, + * }) + * + * @param {import('prosemirror-model').Node} baseDoc + * @param {import('prosemirror-model').Node} proposalDoc + * @param {{ blockTypes?: readonly string[], identityKey?: (n: any) => string | null }} [opts] + * @returns {Array<{ + * kind: 'remove' | 'insert', + * changeId: string, + * basePos?: number, + * baseNodeSize?: number, + * proposalNode?: import('prosemirror-model').Node, + * anchorBasePos?: number, + * }>} + */ +const DEFAULT_BLOCK_TYPES = Object.freeze(['table']); +const defaultIdentityKey = (node) => { + if (!node) return null; + const typeName = node.type?.name ?? 'node'; + const text = typeof node.textContent === 'string' ? node.textContent : ''; + return `${typeName}:${text.replace(/\s+/g, ' ').trim()}`; +}; + +export const computeStructuralDiff = (baseDoc, proposalDoc, opts = {}) => { + const blockTypes = opts.blockTypes ?? DEFAULT_BLOCK_TYPES; + const identityKey = opts.identityKey ?? defaultIdentityKey; + const blockTypeSet = new Set(blockTypes); + + const collectTopLevel = (doc) => { + const entries = []; + doc.forEach((node, offset) => { + const id = identityKey(node); + if (id != null) entries.push({ id, node, pos: offset, end: offset + node.nodeSize }); + }); + return entries; + }; + + const baseEntries = collectTopLevel(baseDoc); + const proposalEntries = collectTopLevel(proposalDoc); + const baseIds = new Set(baseEntries.map((e) => e.id)); + const proposalIds = new Set(proposalEntries.map((e) => e.id)); + + const hunks = []; + const emitRemove = (entry) => { + if (blockTypeSet.has(entry.node.type.name)) { + hunks.push({ + kind: 'remove', + changeId: entry.id, + basePos: entry.pos, + baseNodeSize: entry.node.nodeSize, + }); + } + }; + + // Lockstep walk. For each proposal entry: if it matches something downstream + // in base, drain unmatched base entries between (emitting removes) and consume + // the match. Otherwise it's an insert; anchor at the position of the next + // unmatched base entry (the one this insert is logically replacing), falling + // back to the end of the last matched base block. Anchoring at the unmatched + // base position is what keeps "remove table X" + "insert table X'" visually + // adjacent — without it, an edit to the first table inserts the new copy at + // position 0 instead of next to the original. + let bi = 0; + let lastMatchedBaseEnd = 0; + for (const pe of proposalEntries) { + if (baseIds.has(pe.id)) { + while (bi < baseEntries.length && baseEntries[bi].id !== pe.id) { + if (!proposalIds.has(baseEntries[bi].id)) emitRemove(baseEntries[bi]); + bi += 1; + } + if (bi < baseEntries.length) { + lastMatchedBaseEnd = baseEntries[bi].end; + bi += 1; + } + continue; + } + if (!blockTypeSet.has(pe.node.type.name)) continue; + let anchor = lastMatchedBaseEnd; + if (bi < baseEntries.length && !proposalIds.has(baseEntries[bi].id)) { + anchor = baseEntries[bi].pos; + } + hunks.push({ kind: 'insert', changeId: pe.id, proposalNode: pe.node, anchorBasePos: anchor }); + } + + while (bi < baseEntries.length) { + if (!proposalIds.has(baseEntries[bi].id)) emitRemove(baseEntries[bi]); + bi += 1; + } + + return hunks; +}; diff --git a/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/computeStructuralDiff.test.js b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/computeStructuralDiff.test.js new file mode 100644 index 0000000000..e27a751e1c --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/structural-track-changes/structuralTrackChangesHelpers/computeStructuralDiff.test.js @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { initTestEditor } from '@tests/helpers/helpers.js'; +import { computeStructuralDiff } from './computeStructuralDiff.js'; + +const idAttr = (id) => ({ sdBlockId: id }); + +describe('computeStructuralDiff', () => { + let editor, schema; + beforeEach(() => { + ({ editor } = initTestEditor({ mode: 'text', content: '' })); + schema = editor.schema; + }); + afterEach(() => editor?.destroy()); + + const makeDoc = (blocks) => + schema.nodes.doc.create( + null, + blocks.map((b) => b(schema)), + ); + const p = (id, text) => (schema) => schema.nodes.paragraph.create(idAttr(id), schema.text(text)); + // Default cell text falls back to the id, so each table built here has a + // unique content fingerprint matching the matcher's default behavior. + const table = + (id, text = id) => + (schema) => { + const cell = schema.nodes.tableCell.create(null, schema.nodes.paragraph.create(null, schema.text(text))); + const row = schema.nodes.tableRow.create(null, [cell]); + return schema.nodes.table.create(idAttr(id), [row]); + }; + const tableFingerprint = (text) => `table:${text}`; + + it('emits a remove hunk for a base table absent from proposal', () => { + const base = makeDoc([p('p1', 'a'), table('t1'), p('p2', 'b')]); + const proposal = makeDoc([p('p1', 'a'), p('p2', 'b')]); + const hunks = computeStructuralDiff(base, proposal); + expect(hunks).toHaveLength(1); + expect(hunks[0]).toMatchObject({ kind: 'remove', changeId: tableFingerprint('t1') }); + }); + + it('emits an insert hunk for a proposal table absent from base', () => { + const base = makeDoc([p('p1', 'a')]); + const proposal = makeDoc([p('p1', 'a'), table('t1')]); + const hunks = computeStructuralDiff(base, proposal); + expect(hunks).toHaveLength(1); + expect(hunks[0]).toMatchObject({ kind: 'insert', changeId: tableFingerprint('t1') }); + }); + + it('emits no hunks when both sides have matching tables', () => { + const base = makeDoc([table('t1')]); + const proposal = makeDoc([table('t1')]); + expect(computeStructuralDiff(base, proposal)).toHaveLength(0); + }); + + it('matches tables across independent imports even when sdBlockIds differ', () => { + // Reproduces the bug: two docx files loaded independently get fresh + // sdBlockIds per import. The diff must still recognize identical tables. + const base = makeDoc([table('base-a', 'first'), table('base-b', 'second')]); + const proposal = makeDoc([table('prop-x', 'first'), table('prop-y', 'second')]); + expect(computeStructuralDiff(base, proposal)).toHaveLength(0); + }); + + it('with multiple unchanged tables and one modified, marks only the modified one', () => { + const base = makeDoc([ + table('base-a', 'unchanged-1'), + table('base-b', 'will-edit'), + table('base-c', 'unchanged-2'), + ]); + const proposal = makeDoc([ + table('prop-a', 'unchanged-1'), + table('prop-b', 'edited-content'), + table('prop-c', 'unchanged-2'), + ]); + const hunks = computeStructuralDiff(base, proposal); + expect(hunks).toHaveLength(2); + expect(hunks.some((h) => h.kind === 'remove' && h.changeId === tableFingerprint('will-edit'))).toBe(true); + expect(hunks.some((h) => h.kind === 'insert' && h.changeId === tableFingerprint('edited-content'))).toBe(true); + }); + + it('anchor for insert is end of last shared base block', () => { + const base = makeDoc([p('p1', 'first'), p('p2', 'second')]); + const proposal = makeDoc([p('p1', 'first'), table('t-new'), p('p2', 'second')]); + const hunks = computeStructuralDiff(base, proposal); + const insert = hunks.find((h) => h.kind === 'insert'); + expect(insert).toBeDefined(); + expect(insert.anchorBasePos).toBe(base.child(0).nodeSize); + }); + + it('insert with no shared base block anchors at the unmatched base entry it replaces', () => { + const base = makeDoc([p('p1', 'only')]); + const proposal = makeDoc([table('t-new')]); + const hunks = computeStructuralDiff(base, proposal); + const insert = hunks.find((h) => h.kind === 'insert'); + expect(insert?.anchorBasePos).toBe(0); + }); + + it('insert anchors next to the table it replaces, even when it is the first block in the doc', () => { + // Regression: when the AI edits the first table (no preceding shared + // paragraphs) the proposal table used to anchor at position 0 instead of + // next to the original. With multiple tables in the doc, the inserted + // copy ended up "near some random table" rather than alongside the + // edited one. + const base = makeDoc([table('a', 'orig-a'), table('b', 'unchanged-b'), table('c', 'unchanged-c')]); + const proposal = makeDoc([table("a'", 'edited-a'), table('b', 'unchanged-b'), table('c', 'unchanged-c')]); + const hunks = computeStructuralDiff(base, proposal); + const remove = hunks.find((h) => h.kind === 'remove' && h.changeId === tableFingerprint('orig-a')); + const insert = hunks.find((h) => h.kind === 'insert' && h.changeId === tableFingerprint('edited-a')); + expect(remove).toBeDefined(); + expect(insert).toBeDefined(); + expect(insert.anchorBasePos).toBe(remove.basePos); + }); + + it('insert anchors at the matching unmatched base entry, not the end of the last shared block', () => { + // base has [P1, TableA, TableB]; proposal edits TableB. The natural anchor + // for the inserted TableB' is TableB's position in base (so the new copy + // lands adjacent to the original), not the end of TableA. + const base = makeDoc([p('p1', 'intro'), table('a', 'unchanged-a'), table('b', 'orig-b')]); + const proposal = makeDoc([p('p1', 'intro'), table('a', 'unchanged-a'), table("b'", 'edited-b')]); + const hunks = computeStructuralDiff(base, proposal); + const insert = hunks.find((h) => h.kind === 'insert'); + const baseTableB = base.child(2); + let baseTableBPos = 0; + base.forEach((node, offset) => { + if (node === baseTableB) baseTableBPos = offset; + }); + expect(insert?.anchorBasePos).toBe(baseTableBPos); + }); + + it('blockTypes filter (default ["table"]) restricts insert events', () => { + const base = makeDoc([]); + const proposal = makeDoc([table('t1'), p('p1', 'extra')]); + const hunks = computeStructuralDiff(base, proposal); + expect(hunks.filter((h) => h.kind === 'insert')).toHaveLength(1); + expect(hunks.find((h) => h.kind === 'insert')?.changeId).toBe(tableFingerprint('t1')); + }); + + it('consumer can opt back into sdBlockId-based matching via identityKey override', () => { + // For consumers whose proposals are in-place edits (sdBlockIds preserved + // across base and proposal), id-based matching is sometimes preferable. + const sdBlockIdKey = (node) => node.attrs?.sdBlockId ?? null; + const base = makeDoc([table('t1', 'same')]); + const proposal = makeDoc([table('t2', 'same')]); // same content, different id + const hunks = computeStructuralDiff(base, proposal, { identityKey: sdBlockIdKey }); + // With id-based matching, these differ even though content is identical. + expect(hunks.filter((h) => h.kind === 'remove')).toHaveLength(1); + expect(hunks.filter((h) => h.kind === 'insert')).toHaveLength(1); + }); + + it('custom identityKey override', () => { + const fingerprint = (node) => `${node.type.name}:${node.textContent}`; + const noId = (text) => (schema) => schema.nodes.paragraph.create(null, schema.text(text)); + const base = makeDoc([noId('A'), noId('B')]); + const proposal = makeDoc([noId('A')]); + const hunks = computeStructuralDiff(base, proposal, { + identityKey: fingerprint, + blockTypes: ['paragraph'], + }); + expect(hunks).toHaveLength(1); + expect(hunks[0]).toMatchObject({ kind: 'remove', changeId: 'paragraph:B' }); + }); + + it('does not descend into matched tables (nested table inside matched outer is not double-counted)', () => { + const outerWithCellPara = (id) => (schema) => { + const cell = schema.nodes.tableCell.create(null, schema.nodes.paragraph.create(null, schema.text('inner'))); + const row = schema.nodes.tableRow.create(null, [cell]); + return schema.nodes.table.create(idAttr(id), [row]); + }; + const base = makeDoc([outerWithCellPara('t-outer')]); + const proposal = makeDoc([outerWithCellPara('t-outer')]); + expect(computeStructuralDiff(base, proposal)).toHaveLength(0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js index 6787be6813..b2df8a6361 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/track-changes-extension.test.js @@ -2353,5 +2353,91 @@ describe('TrackChanges extension commands', () => { authorImage: undefined, }); }); + + // Regression test for ALPMO-245: when text is inserted at a position + // where bare text can't live (e.g. at doc end past the last block), PM + // auto-wraps the text in the schema's required parents. The old + // implementation passed `insertedNode.nodeSize` as the mark `to`, which + // covered the wrapper open tokens instead of the trailing characters of + // the actual text — so the last char(s) escaped the trackInsert mark and + // survived reject as orphans. + it('marks the full inserted text when PM auto-wraps at end-of-doc insertion', () => { + const doc = createDoc('Hello'); + const state = createState(doc); + const insertAt = doc.content.size; // past the last block — triggers auto-wrap + + let nextState; + const result = commands.insertTrackedChange({ + from: insertAt, + to: insertAt, + text: 'World.', + })({ + state, + dispatch: (tr) => { + nextState = state.apply(tr); + }, + editor: { + options: { user: { name: 'Test', email: 'test@example.com' } }, + commands: { addCommentReply: vi.fn() }, + }, + }); + + expect(result).toBe(true); + expect(nextState).toBeDefined(); + // The full inserted text — including the trailing '.' — must carry + // trackInsert; no character may sit in an unmarked text node. + expect(getMarkedText(nextState.doc, TrackInsertMarkName)).toBe('World.'); + let unmarkedAppendedText = ''; + nextState.doc.descendants((node) => { + if (!node.isText) return; + if (node.text === 'Hello') return; // pre-existing base content + const hasTrackMark = node.marks.some((m) => m.type.name === TrackInsertMarkName); + if (!hasTrackMark) unmarkedAppendedText += node.text ?? ''; + }); + expect(unmarkedAppendedText).toBe(''); + }); + + it('rejectTrackedChangesBetween leaves no orphan chars after end-of-doc insertion', () => { + const doc = createDoc('Hello'); + const state = createState(doc); + const insertAt = doc.content.size; + + let nextState; + commands.insertTrackedChange({ + from: insertAt, + to: insertAt, + text: 'World.', + })({ + state, + dispatch: (tr) => { + nextState = state.apply(tr); + }, + editor: { + options: { user: { name: 'Test', email: 'test@example.com' } }, + commands: { addCommentReply: vi.fn() }, + }, + }); + + const afterInsert = nextState; + let afterReject; + commands.rejectTrackedChangesBetween( + 0, + afterInsert.doc.content.size, + )({ + state: afterInsert, + dispatch: (tr) => { + afterReject = afterInsert.apply(tr); + }, + editor: { + options: { user: { name: 'Test', email: 'test@example.com' } }, + commands: { addCommentReply: vi.fn() }, + }, + }); + + expect(afterReject).toBeDefined(); + // Doc must be restored to just 'Hello' — no orphan '.' or 'd' or 'l' + // surviving in a new wrapper paragraph the AI-style insertion created. + expect(afterReject.doc.textContent).toBe('Hello'); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/blockTrackedChangePassthrough.test.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/blockTrackedChangePassthrough.test.js new file mode 100644 index 0000000000..2bd034c75b --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/blockTrackedChangePassthrough.test.js @@ -0,0 +1,73 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { EditorState } from 'prosemirror-state'; +import { Slice, Fragment } from 'prosemirror-model'; +import { ReplaceStep } from 'prosemirror-transform'; +import { trackedTransaction } from './trackedTransaction.js'; +import { TrackInsertMarkName, TrackDeleteMarkName } from '../constants.js'; +import { initTestEditor } from '@tests/helpers/helpers.js'; + +describe('trackedTransaction — pass-through for pre-marked block content', () => { + let editor, schema, basePlugins; + const user = { name: 'Block Tester', email: 'block@example.com' }; + + beforeEach(() => { + ({ editor } = initTestEditor({ mode: 'text', content: '' })); + schema = editor.schema; + basePlugins = editor.state.plugins; + }); + afterEach(() => { + vi.restoreAllMocks(); + editor?.destroy(); + editor = null; + }); + + const createState = (doc) => EditorState.create({ schema, doc, plugins: basePlugins }); + + const buildPreMarkedTable = (revisionGroupId, rowCount = 2) => { + const cell = schema.nodes.tableCell.create(null, schema.nodes.paragraph.create(null, schema.text('hello'))); + const rows = []; + for (let i = 0; i < rowCount; i += 1) { + rows.push( + schema.nodes.tableRow.create({ trackChange: { type: 'rowInsert', id: `row-${i}`, revisionGroupId } }, [cell]), + ); + } + return schema.nodes.table.create({ sdBlockId: 'tbl-1' }, rows); + }; + + const inlineTrackedMarksOnText = (doc) => { + let count = 0; + doc.descendants((node) => { + if (!node.isText) return; + node.marks.forEach((m) => { + if (m.type.name === TrackInsertMarkName || m.type.name === TrackDeleteMarkName) count += 1; + }); + }); + return count; + }; + + it('ReplaceStep inserting a pre-marked table passes through without inline-mark wrapping', () => { + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, schema.text('before'))]); + const state = createState(doc); + const tr = state.tr; + const preMarkedTable = buildPreMarkedTable('group-1', 2); + const insertPos = state.doc.content.size; + tr.step(new ReplaceStep(insertPos, insertPos, new Slice(Fragment.from(preMarkedTable), 0, 0))); + const result = trackedTransaction({ tr, state, user }); + const nextDoc = state.apply(result).doc; + const insertedTable = nextDoc.child(nextDoc.childCount - 1); + expect(insertedTable.type.name).toBe('table'); + insertedTable.forEach((row) => { + expect(row.attrs.trackChange?.type).toBe('rowInsert'); + }); + expect(inlineTrackedMarksOnText(nextDoc)).toBe(0); + }); + + it('normal text insertion still gets wrapped with inline trackInsert marks (regression guard)', () => { + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, schema.text('hello world'))]); + const state = createState(doc); + const tr = state.tr.insertText('X', 1); + const result = trackedTransaction({ tr, state, user }); + const nextDoc = state.apply(result).doc; + expect(inlineTrackedMarksOnText(nextDoc)).toBeGreaterThan(0); + }); +}); diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js index 16d823955f..7778565d11 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/trackChangesHelpers/trackedTransaction.js @@ -361,6 +361,31 @@ const getPendingDeadKeyPlaceholder = ({ tr, newTr, user }) => { }; }; +/** + * Detects whether a ReplaceStep's slice already carries block-level + * `trackChange` metadata via PM node attributes (e.g. a table whose rows + * have `trackChange.insert` stamped by `applyHunks`). When true, the slice + * should be applied as-is — wrapping its inline content with `trackInsert` + * marks would double-track. + * + * @param {import('prosemirror-model').Slice} slice + * @returns {boolean} + */ +const sliceContainsPreMarkedBlockTrackedChange = (slice) => { + if (!slice || !slice.content || slice.content.size === 0) return false; + let found = false; + slice.content.descendants((node) => { + if (found) return false; + const tc = node?.attrs?.trackChange; + if (tc && (tc.type === 'rowInsert' || tc.type === 'rowDelete')) { + found = true; + return false; + } + return undefined; + }); + return found; +}; + /** * Process a transaction through the track-changes pipeline and return * a modified transaction with tracked-change marks applied (or the @@ -433,6 +458,18 @@ export const trackedTransaction = ({ tr, state, user, replacements = 'paired' }) step = normalizeCompositionInsertStep({ step, doc, tr, user, pendingDeadKeyPlaceholder }); + // Block-level tracked-change replay path: the inserted slice already + // carries row-level tracked metadata via PM node attrs (see applyHunks). + // Wrapping the inner cell content with inline trackInsert marks would + // double-track. Apply such steps as-is — but still append to `map` so + // subsequent steps in the same transaction map through this step's + // changes (matches the pass-through pattern in replaceStep.js). + if (step instanceof ReplaceStep && sliceContainsPreMarkedBlockTrackedChange(step.slice)) { + newTr.step(step); + map.appendMap(step.getMap()); + return; + } + if (step instanceof ReplaceStep) { replaceStep({ state, diff --git a/packages/super-editor/src/editors/v1/index.js b/packages/super-editor/src/editors/v1/index.js index fcf98a5d4b..4645d3d4f6 100644 --- a/packages/super-editor/src/editors/v1/index.js +++ b/packages/super-editor/src/editors/v1/index.js @@ -41,6 +41,8 @@ import AIWriter from './components/toolbar/AIWriter.vue'; import * as fieldAnnotationHelpers from './extensions/field-annotation/fieldAnnotationHelpers/index.js'; import * as trackChangesHelpers from './extensions/track-changes/trackChangesHelpers/index.js'; import { TrackChangesBasePluginKey } from './extensions/track-changes/plugins/index.js'; +import { StructuralTrackChanges, computeStructuralDiff } from './extensions/structural-track-changes/index.js'; +import { enumerateStructuralRowChanges } from './extensions/track-changes/trackChangesHelpers/structuralRowChanges.js'; import { CommentsPluginKey, createOrUpdateTrackedChangeComment } from './extensions/comment/comments-plugin.js'; import { AnnotatorHelpers } from '@helpers/annotator.js'; import { SectionHelpers } from '@extensions/structured-content/document-section/index.js'; @@ -109,6 +111,10 @@ export { helpers, fieldAnnotationHelpers, trackChangesHelpers, + // Structural (block-level) tracked changes + StructuralTrackChanges, + computeStructuralDiff, + enumerateStructuralRowChanges, /** @internal */ AnnotatorHelpers, SectionHelpers, diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/diff_after_table_remove.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/diff_after_table_remove.docx new file mode 100644 index 0000000000..1904c3fd0f Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/diff_after_table_remove.docx differ diff --git a/packages/super-editor/src/editors/v1/tests/data/diffing/diff_before_table_remove.docx b/packages/super-editor/src/editors/v1/tests/data/diffing/diff_before_table_remove.docx new file mode 100644 index 0000000000..6d38931b1c Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/diffing/diff_before_table_remove.docx differ diff --git a/packages/superdoc/src/index.js b/packages/superdoc/src/index.js index dfa2324cff..cbbcdfba79 100644 --- a/packages/superdoc/src/index.js +++ b/packages/superdoc/src/index.js @@ -34,6 +34,10 @@ import { AIWriter, ContextMenu, SlashMenu, + // Structural (block-level) tracked changes + StructuralTrackChanges, + computeStructuralDiff, + enumerateStructuralRowChanges, } from '@superdoc/super-editor'; import { DOCX, PDF, HTML, getFileObject, compareVersions } from '@superdoc/common'; // @ts-expect-error Vite resolves DOCX asset URL imports; plain tsc does not. @@ -299,4 +303,9 @@ export { AIWriter, ContextMenu, SlashMenu, + + // Structural (block-level) tracked changes + StructuralTrackChanges, + computeStructuralDiff, + enumerateStructuralRowChanges, }; diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json index ab2169fc06..70556f0758 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-08T11:16:20.709Z", + "generatedAt": "2026-06-23T17:41:41.711Z", "ticket": "SD-3212 PR A0", "package": "superdoc", "rootExport": { @@ -514,6 +514,7 @@ "PresentationEditor", "SectionHelpers", "SlashMenu", + "StructuralTrackChanges", "SuperConverter", "SuperDoc", "SuperEditor", @@ -524,10 +525,12 @@ "assertNodeType", "buildTheme", "compareVersions", + "computeStructuralDiff", "createTheme", "createZip", "defineMark", "defineNode", + "enumerateStructuralRowChanges", "fieldAnnotationHelpers", "getActiveFormatting", "getAllowedImageDimensions", @@ -561,6 +564,7 @@ "PresentationEditor", "SectionHelpers", "SlashMenu", + "StructuralTrackChanges", "SuperConverter", "SuperDoc", "SuperEditor", @@ -571,10 +575,12 @@ "assertNodeType", "buildTheme", "compareVersions", + "computeStructuralDiff", "createTheme", "createZip", "defineMark", "defineNode", + "enumerateStructuralRowChanges", "fieldAnnotationHelpers", "getActiveFormatting", "getAllowedImageDimensions", @@ -595,9 +601,9 @@ "counts": { "types.import": 237, "types.require": 237, - "import": 41, - "require": 41, - "union": 237 + "import": 44, + "require": 44, + "union": 240 }, "divergences": { "typesImportVsRequire": { @@ -807,7 +813,7 @@ "ViewingVisibilityConfig", "VirtualizationOptions" ], - "runtimeOnly": [] + "runtimeOnly": ["StructuralTrackChanges", "computeStructuralDiff", "enumerateStructuralRowChanges"] } } } diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md index dee78477c2..4055b912c4 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md +++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md @@ -1,6 +1,6 @@ # superdoc root export inventory (SD-3212 PR A0) -Generated: 2026-06-08T11:16:20.709Z +Generated: 2026-06-23T17:41:41.711Z Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` ## Counts @@ -9,9 +9,9 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` |---|---|---| | types.import | `./dist/superdoc/src/public/index.d.ts` | 237 | | types.require | `./dist/superdoc/src/public/index.d.cts` | 237 | -| import | `./dist/superdoc.es.js` | 41 | -| require | `./dist/superdoc.cjs` | 41 | -| **union** | | **237** | +| import | `./dist/superdoc.es.js` | 44 | +| require | `./dist/superdoc.cjs` | 44 | +| **union** | | **240** | ## Divergences @@ -20,7 +20,13 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` - ESM only (not in CJS): 0 - CJS only (not in ESM): 0 - typed but no runtime export (phantom risk): 196 -- runtime export but not typed (silent shadow on root): 0 +- runtime export but not typed (silent shadow on root): 3 + +### Runtime-only names (no type) + +- `StructuralTrackChanges` +- `computeStructuralDiff` +- `enumerateStructuralRowChanges` ### Type-only names (no runtime) @@ -244,7 +250,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `CollaborationProvider` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `Command` | ✓ | ✓ | | | 3 | ✓ | 78 | 0 | 8 | ✓ | | `CommandProps` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | ✓ | -| `Comment` | ✓ | ✓ | | | 5 | ✓ | 29 | 3 | 45 | | +| `Comment` | ✓ | ✓ | | | 5 | ✓ | 28 | 3 | 45 | | | `CommentAddress` | ✓ | ✓ | | | 1 | ✓ | 4 | 0 | 3 | | | `CommentConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `CommentElement` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | @@ -261,12 +267,12 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `ContextMenuItem` | ✓ | ✓ | | | 2 | ✓ | 4 | 0 | 5 | | | `ContextMenuSection` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `CoreCommandMap` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | ✓ | -| `DOCX` | ✓ | ✓ | ✓ | ✓ | 2 | | 160 | 25 | 55 | ✓ | +| `DOCX` | ✓ | ✓ | ✓ | ✓ | 2 | | 160 | 25 | 57 | ✓ | | `DirectSurfaceRequest` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `DocRange` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | -| `Document` | ✓ | ✓ | | | 2 | | 291 | 56 | 110 | ✓ | +| `Document` | ✓ | ✓ | | | 2 | | 294 | 56 | 111 | ✓ | | `DocumentApi` | ✓ | ✓ | | | 3 | ✓ | 0 | 11 | 4 | ✓ | -| `DocumentFontOption` | ✓ | ✓ | | | 0 | | 0 | 0 | 0 | | +| `DocumentFontOption` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | | `DocumentMode` | ✓ | ✓ | | | 3 | ✓ | 2 | 16 | 3 | | | `DocumentProtectionState` | ✓ | ✓ | | | 1 | ✓ | 1 | 0 | 1 | | | `DocxFileEntry` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | @@ -282,7 +288,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `EditorTransactionEvent` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `EditorUpdateEvent` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `EditorView` | ✓ | ✓ | | | 4 | ✓ | 2 | 0 | 0 | ✓ | -| `EntityAddress` | ✓ | ✓ | | | 2 | ✓ | 276 | 0 | 8 | | +| `EntityAddress` | ✓ | ✓ | | | 2 | ✓ | 297 | 0 | 8 | | | `ExportDocxParams` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `ExportFormat` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `ExportOptions` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | @@ -309,7 +315,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `FontsChangedPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `FontsConfig` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | | | `FontsResolvedPayload` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | -| `HTML` | ✓ | ✓ | ✓ | ✓ | 2 | | 87 | 12 | 202 | | +| `HTML` | ✓ | ✓ | ✓ | ✓ | 2 | | 87 | 12 | 205 | | | `ImageDeselectedEvent` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `ImageSelectedEvent` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `IntentSurfaceRequest` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | @@ -330,7 +336,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `Modules` | ✓ | ✓ | | | 2 | ✓ | 4 | 0 | 0 | | | `NavigableAddress` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `OpenOptions` | ✓ | ✓ | | | 3 | ✓ | 1 | 0 | 0 | | -| `PDF` | ✓ | ✓ | ✓ | ✓ | 2 | | 37 | 0 | 1 | ✓ | +| `PDF` | ✓ | ✓ | ✓ | ✓ | 2 | | 38 | 0 | 1 | ✓ | | `PageMargins` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `PageSize` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `PageStyles` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | @@ -383,13 +389,14 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `SelectionCommandContext` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `SelectionCurrentInput` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | | | `SelectionHandle` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | -| `SelectionInfo` | ✓ | ✓ | | | 2 | ✓ | 6 | 0 | 1 | | +| `SelectionInfo` | ✓ | ✓ | | | 2 | ✓ | 11 | 0 | 1 | | | `SlashMenu` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 1 | | -| `StoryLocator` | ✓ | ✓ | | | 1 | ✓ | 123 | 0 | 3 | | +| `StoryLocator` | ✓ | ✓ | | | 1 | ✓ | 1037 | 0 | 3 | | +| `StructuralTrackChanges` | | | ✓ | ✓ | 0 | | 0 | 0 | 0 | | | `SuperConverter` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 3 | ✓ | -| `SuperDoc` | ✓ | ✓ | ✓ | ✓ | 22 | | 1046 | 190 | 250 | ✓ | +| `SuperDoc` | ✓ | ✓ | ✓ | ✓ | 23 | | 1088 | 194 | 250 | ✓ | | `SuperDocAwarenessUpdatePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | -| `SuperDocCommentsUpdatePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | +| `SuperDocCommentsUpdatePayload` | ✓ | ✓ | | | 2 | | 2 | 0 | 0 | | | `SuperDocEditorPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocExceptionEditorPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | | `SuperDocExceptionPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | | @@ -423,15 +430,15 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `SurfaceResolver` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `SurfacesModuleConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `TelemetryEvent` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | -| `TextAddress` | ✓ | ✓ | | | 3 | ✓ | 404 | 0 | 7 | | -| `TextSegment` | ✓ | ✓ | | | 3 | ✓ | 8 | 0 | 4 | | -| `TextTarget` | ✓ | ✓ | | | 3 | ✓ | 45 | 0 | 10 | | -| `Toolbar` | ✓ | ✓ | ✓ | ✓ | 1 | | 35 | 7 | 15 | | +| `TextAddress` | ✓ | ✓ | | | 3 | ✓ | 416 | 0 | 7 | | +| `TextSegment` | ✓ | ✓ | | | 3 | ✓ | 9 | 0 | 4 | | +| `TextTarget` | ✓ | ✓ | | | 3 | ✓ | 52 | 0 | 10 | | +| `Toolbar` | ✓ | ✓ | ✓ | ✓ | 1 | | 38 | 7 | 17 | | | `TrackChangeAuthor` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `TrackChangesAuthorColorsConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | | `TrackChangesBasePluginKey` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | ✓ | | `TrackChangesModuleConfig` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | | -| `TrackedChangeAddress` | ✓ | ✓ | | | 1 | ✓ | 13 | 0 | 3 | | +| `TrackedChangeAddress` | ✓ | ✓ | | | 1 | ✓ | 16 | 0 | 3 | | | `TrackedChangesMode` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `TrackedChangesOverrides` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | | | `Transaction` | ✓ | ✓ | | | 3 | ✓ | 5 | 0 | 0 | ✓ | @@ -445,10 +452,12 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc` | `assertNodeType` | ✓ | ✓ | ✓ | ✓ | 1 | | 2 | 0 | 1 | ✓ | | `buildTheme` | ✓ | ✓ | ✓ | ✓ | 1 | | 4 | 0 | 1 | | | `compareVersions` | ✓ | ✓ | ✓ | ✓ | 0 | | 0 | 0 | 1 | | +| `computeStructuralDiff` | | | ✓ | ✓ | 0 | | 0 | 0 | 0 | | | `createTheme` | ✓ | ✓ | ✓ | ✓ | 1 | | 21 | 8 | 1 | | | `createZip` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | ✓ | | `defineMark` | ✓ | ✓ | ✓ | ✓ | 2 | | 3 | 0 | 1 | ✓ | | `defineNode` | ✓ | ✓ | ✓ | ✓ | 2 | | 4 | 0 | 1 | ✓ | +| `enumerateStructuralRowChanges` | | | ✓ | ✓ | 0 | | 0 | 0 | 0 | | | `fieldAnnotationHelpers` | ✓ | ✓ | ✓ | ✓ | 1 | | 2 | 0 | 3 | | | `getActiveFormatting` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 2 | | | `getAllowedImageDimensions` | ✓ | ✓ | ✓ | ✓ | 2 | | 0 | 0 | 1 | | diff --git a/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt b/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt index 43aa841de4..ccd45c505e 100644 --- a/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt +++ b/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt @@ -135,6 +135,7 @@ SelectionSlice SelectorFn SlashMenu StoryLocator +StructuralTrackChanges Subscribable SuperConverter SuperDocEditorLike @@ -178,12 +179,14 @@ ViewportRect ViewportRectResult VirtualizationOptions assertNodeType +computeStructuralDiff createHeadlessToolbar createOrUpdateTrackedChangeComment createSuperDocUI createZip defineMark defineNode +enumerateStructuralRowChanges fieldAnnotationHelpers getActiveFormatting getAllowedImageDimensions