Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f36dbd0
feat(track-changes): block-level structural tracked changes for tables
shri-scale May 16, 2026
b748ce2
fix(track-changes): address PR review on block-level structural track…
shri-scale May 16, 2026
7a6637f
feat(track-changes): register StructuralTrackChanges in getStarterExt…
shri-scale May 16, 2026
6a490ac
fix(track-changes): skip block-level walk for docs without tracked rows
shri-scale May 16, 2026
5c1d26c
fix(presentation-editor): scope stale-target redirect to SuperDoc-own…
shri-scale May 18, 2026
824d367
feat(track-changes): handle block-level changes in accept/rejectAll a…
shri-scale May 18, 2026
b6d4e62
fix(presentation-editor): scope stale-target check via .sd-editor-scoped
shri-scale May 18, 2026
92a454b
chore(types): refresh SD-3176 snapshot for StructuralTrackChanges export
shri-scale May 18, 2026
3af65c4
fix(track-changes): mark full inserted range when PM auto-wraps text
shri-scale May 27, 2026
25b1c23
fix(layout): invalidate row caches on tableRow.trackChange attr changes
shri-scale May 27, 2026
fc1502e
fix(structural-track-changes): default identity key to content finger…
shri-scale May 27, 2026
1a40dcd
fix(track-changes): append step map for block-level pre-marked shortcut
shri-scale Jun 5, 2026
583970d
fix(track-changes): reject id-less trackChange attrs at parse time
shri-scale Jun 5, 2026
9ce64b1
fix(track-changes): align block-level producer with upstream OOXML fo…
shri-scale Jun 10, 2026
69e6995
fix(layout): remove orphan deriveBlockVersion duplicate from painter
shri-scale Jun 10, 2026
afcce8b
chore(structural-track-changes): add // @ts-check to satisfy jsdoc ra…
shri-scale Jun 15, 2026
d002c66
chore(snapshot): add StructuralTrackChanges + computeStructuralDiff t…
shri-scale Jun 15, 2026
5af0ac9
chore(snapshot): scope computeStructuralDiff to runtime exports only
shri-scale Jun 15, 2026
ff185fa
test(painter-dom): update block-level tracked-change scope test for c…
shri-scale Jun 15, 2026
851b4b9
feat(track-changes): extend block-level tracked changes to paragraphs
shri-scale Jun 18, 2026
daccb98
refactor(track-changes): route block-level changes through existing s…
shri-scale Jun 23, 2026
c2b4ad3
style: prettier fix on applyHunks.js
shri-scale Jun 23, 2026
da87731
chore(snapshot): add enumerateStructuralRowChanges to root + super-ed…
shri-scale Jun 23, 2026
b961983
fix(structural-track-changes): anchor table-edit inserts next to the …
shri-scale Jun 24, 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
16 changes: 15 additions & 1 deletion packages/layout-engine/contracts/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
12 changes: 12 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:…'`).
Expand Down Expand Up @@ -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 = {
Expand Down
15 changes: 15 additions & 0 deletions packages/layout-engine/layout-bridge/src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
14 changes: 14 additions & 0 deletions packages/layout-engine/layout-resolved/src/versionSignature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] : []);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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 });
Expand Down
56 changes: 56 additions & 0 deletions packages/layout-engine/painters/dom/src/runs/tracked-changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions packages/layout-engine/painters/dom/src/styles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
25 changes: 25 additions & 0 deletions packages/layout-engine/painters/dom/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tr> via the schema's
* renderDOM. The painter-side stamping (per-cell <div> in layout mode) is
* styled by BLOCK_TRACK_CHANGE_STYLES in
* packages/layout-engine/painters/dom/src/styles.ts to keep document visuals
* inside the painter's stylesheet boundary.
*/
.sd-editor-scoped .ProseMirror tr[data-track-change='delete'] {
background-color: var(--sd-block-tracked-deleted-bg, rgba(203, 14, 71, 0.18));
outline: var(--sd-block-tracked-deleted-outline-width, 2px) dashed var(--sd-block-tracked-deleted-outline, #cb0e47);
outline-offset: var(--sd-block-tracked-deleted-outline-offset, -1px);
}

.sd-editor-scoped .ProseMirror tr[data-track-change='delete'] td,
.sd-editor-scoped .ProseMirror tr[data-track-change='delete'] th {
text-decoration: line-through;
text-decoration-color: var(--sd-block-tracked-deleted-outline, #cb0e47);
}

.sd-editor-scoped .ProseMirror tr[data-track-change='insert'] {
background-color: var(--sd-block-tracked-inserted-bg, rgba(57, 156, 114, 0.18));
outline: var(--sd-block-tracked-inserted-outline-width, 2px) dashed var(--sd-block-tracked-inserted-outline, #00853d);
outline-offset: var(--sd-block-tracked-inserted-outline-offset, -1px);
}
/* Block-level tracked changes - end */

/* Collaboration cursors */
.sd-editor-scoped .ProseMirror > .ProseMirror-yjs-cursor:first-child {
margin-top: 16px;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ vi.mock('../tracked-changes.js', () => ({
shouldHideTrackedNode: vi.fn(),
annotateBlockWithTrackedChange: vi.fn(),
applyTrackedChangesModeToRuns: vi.fn(),
buildBlockTrackedChangeMetaFromAttr: vi.fn(() => undefined),
}));

vi.mock('../attributes/paragraph-styles.js', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ import type { ConverterContext } from '../converter-context.js';
import { computeParagraphAttrs, deepClone } from '../attributes/index.js';
import { shouldRequirePageBoundary, hasIntrinsicBoundarySignals, createSectionBreakBlock } from '../sections/index.js';
import { trackedChangesCompatible, applyMarksToRun, collectTrackedChangeFromMarks } from '../marks/index.js';
import { applyTrackedChangesModeToRuns } from '../tracked-changes.js';
import {
applyTrackedChangesModeToRuns,
annotateBlockWithTrackedChange,
buildBlockTrackedChangeMetaFromAttr,
} from '../tracked-changes.js';
import { textNodeToRun } from './inline-converters/text-run.js';
import { DEFAULT_HYPERLINK_CONFIG, TOKEN_INLINE_TYPES } from '../constants.js';
import { computeRunAttrs, hasExplicitParagraphRunProperties } from '../attributes/paragraph.js';
Expand Down Expand Up @@ -606,6 +610,14 @@ export function paragraphToFlowBlocks({
const defaultSize =
usePreviousFont && previousParagraphFont.fontSize ? previousParagraphFont.fontSize : extracted.defaultSize;

// Whole-paragraph structural tracked change (insert/delete) stamped on the
// PM node's `trackChange` attr by applyHunks. Surfaced only when tracked
// changes are enabled and not 'off', matching the row + inline-mark paths.
const blockTrackedChange =
trackedChangesConfig?.enabled && trackedChangesConfig.mode !== 'off'
? buildBlockTrackedChangeMetaFromAttr(para.attrs as Record<string, unknown> | undefined, storyKey)
: undefined;

const finalizeParagraphBlocks = (outputBlocks: FlowBlock[]): FlowBlock[] => {
outputBlocks.forEach((block) => {
if (block.kind === 'paragraph') {
Expand All @@ -614,6 +626,7 @@ export function paragraphToFlowBlocks({
converterContext,
para,
});
annotateBlockWithTrackedChange(block, blockTrackedChange, trackedChangesConfig);
}
});
return outputBlocks;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -799,12 +799,21 @@ const parseTableRow = (args: ParseTableRowArgs): TableRow | null => {
// The PM table-row extension has both cantSplit as a top-level attr AND within tableRowProperties
// For layout engine, we only need to read from tableRowProperties.cantSplit

return {
const row: TableRow = {
id: context.nextBlockId(`row-${rowIndex}`),
cells,
attrs,
sourceAnchor: sourceAnchorFromNode(rowNode),
};

// Row-level tracked changes flow through `row.attrs.trackedChange`, populated
// above via `buildRowTrackedChangeMeta` from the OOXML-aligned
// `attrs.trackChange.type` shape. The legacy top-level `row.trackedChange`
// copy this block used to populate from the old `{ kind }` shape is gone now
// that applyHunks writes the OOXML format; the painter and cache layers all
// read `row.attrs.trackedChange`.

return row;
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,37 @@ export const shouldHideTrackedNode = (meta: TrackedChangeMeta | undefined, confi
return false;
};

/**
* Build {@link TrackedChangeMeta} from a block node's `trackChange` attribute
* (the block-level structural tracked change written by `applyHunks`). Accepts
* the canonical `{ kind: 'insert' | 'delete' }` shape used by paragraphs and
* the OOXML `{ type: 'rowInsert' | 'rowDelete' }` shape used by table rows.
* Mirrors `buildTrackedChangeMetaFromMark` (inline) and the row builder in
* `converters/table.ts`. Returns undefined when the node is untracked.
*/
export const buildBlockTrackedChangeMetaFromAttr = (
attrs: Record<string, unknown> | undefined,
storyKey?: string,
): TrackedChangeMeta | undefined => {
const tc = attrs?.trackChange as Record<string, unknown> | null | undefined;
if (!tc || typeof tc !== 'object') return undefined;
const kind: TrackedChangeKind | undefined =
tc.kind === 'insert' || tc.kind === 'delete'
? tc.kind
: tc.type === 'rowInsert'
? 'insert'
: tc.type === 'rowDelete'
? 'delete'
: undefined;
if (!kind) return undefined;
const meta: TrackedChangeMeta = { kind, id: deriveTrackedChangeId(kind, tc) };
if (typeof tc.author === 'string' && tc.author) meta.author = tc.author;
if (typeof tc.authorEmail === 'string' && tc.authorEmail) meta.authorEmail = tc.authorEmail;
if (typeof tc.date === 'string' && tc.date) meta.date = tc.date;
if (typeof storyKey === 'string' && storyKey.length > 0) meta.storyKey = storyKey;
return meta;
};

/**
* Annotates a block with tracked change metadata if applicable
*
Expand Down
Loading