Skip to content

Commit 70003e4

Browse files
shri-scaleclaude
andcommitted
fix(track-changes): address PR review on block-level structural tracked changes
- Move painter-targeting CSS into BLOCK_TRACK_CHANGE_STYLES in the painter's styles module so document visuals live behind the painter boundary; keep contenteditable-side rules scoped under .sd-editor-scoped .ProseMirror tr. - Drop stale isBlockLevel entries from previous state before merging in freshly-computed block entries, so accept/reject clears the bubble. - renderDOM now emits data-track-change-id and data-track-change-operation alongside data-track-change so HTML round-trips preserve enough state for getBlockTrackedChanges and acceptTrackedChangeById to resolve the row. Adds regression tests: stale-entry pruning in comments-plugin apply(), renderDOM/parseDOM round-trip in blockTrackedChangeAttr, and a styles.ts guard that fails if a bare [data-track-change=...] selector regresses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b149e41 commit 70003e4

7 files changed

Lines changed: 128 additions & 27 deletions

File tree

packages/layout-engine/painters/dom/src/styles.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,19 @@ describe('ensureTrackChangeStyles', () => {
7070
);
7171
expect(cssText).not.toMatch(/track-format-dec\.highlighted\.track-change-focused\s*\{[\s\S]*border-bottom-width:/);
7272
});
73+
74+
it('block-level tracked-change rules are scoped to .superdoc-layout, not bare selectors', () => {
75+
ensureTrackChangeStyles(document);
76+
77+
const styleEl = document.querySelector('[data-superdoc-track-change-styles="true"]');
78+
const cssText = styleEl?.textContent ?? '';
79+
80+
// Scoped selectors present
81+
expect(cssText).toContain(".superdoc-layout [data-track-change='delete']");
82+
expect(cssText).toContain(".superdoc-layout [data-track-change='insert']");
83+
84+
// No bare `[data-track-change=...] {` selector — would leak to PM mirror
85+
// and any host page that uses the same attribute name.
86+
expect(cssText).not.toMatch(/^\s*\[data-track-change=/m);
87+
});
7388
});

packages/layout-engine/painters/dom/src/styles.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,34 @@ const TRACK_CHANGE_STYLES = `
313313
.superdoc-layout .track-format-dec.highlighted.track-change-focused {
314314
background-color: var(--sd-tracked-changes-format-background-focused, #ffd70033);
315315
}
316+
317+
/*
318+
* Block-level tracked changes (row granularity for v1).
319+
*
320+
* The DomPainter stamps data-track-change on each cell <div> of a tracked
321+
* row (no <tr> exists in layout mode; cells are absolutely positioned).
322+
* Scoping to .superdoc-layout keeps these rules out of the contenteditable
323+
* mirror; the editor stylesheet handles the PM <tr> path separately.
324+
*/
325+
.superdoc-layout [data-track-change='delete'] {
326+
background-color: var(--sd-block-tracked-deleted-bg, rgba(203, 14, 71, 0.18));
327+
outline:
328+
var(--sd-block-tracked-deleted-outline-width, 2px)
329+
dashed
330+
var(--sd-block-tracked-deleted-outline, #cb0e47);
331+
outline-offset: var(--sd-block-tracked-deleted-outline-offset, -1px);
332+
text-decoration: line-through;
333+
text-decoration-color: var(--sd-block-tracked-deleted-outline, #cb0e47);
334+
}
335+
336+
.superdoc-layout [data-track-change='insert'] {
337+
background-color: var(--sd-block-tracked-inserted-bg, rgba(57, 156, 114, 0.18));
338+
outline:
339+
var(--sd-block-tracked-inserted-outline-width, 2px)
340+
dashed
341+
var(--sd-block-tracked-inserted-outline, #00853d);
342+
outline-offset: var(--sd-block-tracked-inserted-outline-offset, -1px);
343+
}
316344
`;
317345

318346
const FORMATTING_MARKS_STYLES = `

packages/super-editor/src/editors/v1/assets/styles/elements/prosemirror.css

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -286,35 +286,30 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html
286286
/* Track changes - end */
287287

288288
/*
289-
* Block-level tracked changes (row granularity for v1).
289+
* Block-level tracked changes (row granularity for v1) — contenteditable path.
290290
*
291-
* The `data-track-change` attribute is stamped in two places:
292-
* 1. ProseMirror's contenteditable <tr> via the schema's renderDOM
293-
* (hidden in layout-engine mode but kept consistent).
294-
* 2. The DomPainter's per-cell <div> elements (no <tr> exists in layout
295-
* mode; cells are absolutely positioned). Every cell of a tracked row
296-
* carries the attribute → a continuous red/green strip across the row.
297-
*
298-
* Selectors are intentionally minimal so the rule applies wherever the painter
299-
* places the cell — across re-renders and table-fragment boundaries.
291+
* The data-track-change attribute is stamped on PM's <tr> via the schema's
292+
* renderDOM. The painter-side stamping (per-cell <div> in layout mode) is
293+
* styled by BLOCK_TRACK_CHANGE_STYLES in
294+
* packages/layout-engine/painters/dom/src/styles.ts to keep document visuals
295+
* inside the painter's stylesheet boundary.
300296
*/
301-
[data-track-change='delete'] {
302-
background-color: var(--sd-block-tracked-deleted-bg, rgba(203, 14, 71, 0.18)) !important;
303-
outline: var(--sd-block-tracked-deleted-outline-width, 2px) dashed var(--sd-block-tracked-deleted-outline, #cb0e47) !important;
304-
outline-offset: var(--sd-block-tracked-deleted-outline-offset, -1px) !important;
297+
.sd-editor-scoped .ProseMirror tr[data-track-change='delete'] {
298+
background-color: var(--sd-block-tracked-deleted-bg, rgba(203, 14, 71, 0.18));
299+
outline: var(--sd-block-tracked-deleted-outline-width, 2px) dashed var(--sd-block-tracked-deleted-outline, #cb0e47);
300+
outline-offset: var(--sd-block-tracked-deleted-outline-offset, -1px);
305301
}
306302

307303
.sd-editor-scoped .ProseMirror tr[data-track-change='delete'] td,
308-
.sd-editor-scoped .ProseMirror tr[data-track-change='delete'] th,
309-
.superdoc-table-fragment [data-track-change='delete'] {
304+
.sd-editor-scoped .ProseMirror tr[data-track-change='delete'] th {
310305
text-decoration: line-through;
311306
text-decoration-color: var(--sd-block-tracked-deleted-outline, #cb0e47);
312307
}
313308

314-
[data-track-change='insert'] {
315-
background-color: var(--sd-block-tracked-inserted-bg, rgba(57, 156, 114, 0.18)) !important;
316-
outline: var(--sd-block-tracked-inserted-outline-width, 2px) dashed var(--sd-block-tracked-inserted-outline, #00853d) !important;
317-
outline-offset: var(--sd-block-tracked-inserted-outline-offset, -1px) !important;
309+
.sd-editor-scoped .ProseMirror tr[data-track-change='insert'] {
310+
background-color: var(--sd-block-tracked-inserted-bg, rgba(57, 156, 114, 0.18));
311+
outline: var(--sd-block-tracked-inserted-outline-width, 2px) dashed var(--sd-block-tracked-inserted-outline, #00853d);
312+
outline-offset: var(--sd-block-tracked-inserted-outline-offset, -1px);
318313
}
319314
/* Block-level tracked changes - end */
320315

packages/super-editor/src/editors/v1/extensions/comment/blockTrackedChangeBubble.test.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,31 @@ describe('comments-plugin — block-level tracked-change registration', () => {
5050
expect(tracked['OP1']).toBeUndefined();
5151
expect(tracked['r1']).toBeUndefined();
5252
});
53+
54+
it('drops a block-level entry once its row is no longer tracked (resolved op)', () => {
55+
const table = schema.nodes.table.create({ sdBlockId: 't1' }, [makeRow('delete', 'r1', 'OP1')]);
56+
installDoc([table]);
57+
expect(CommentsPluginKey.getState(editor.state).trackedChanges['OP1']).toBeDefined();
58+
59+
// Walk the doc to find the tracked row, then clear its trackChange attr
60+
// through a transaction. This exercises the apply() reducer's stale-entry
61+
// pruning rather than recomputing state from init().
62+
let rowPos = null;
63+
let rowNode = null;
64+
editor.state.doc.descendants((node, pos) => {
65+
if (node.type.name === 'tableRow' && node.attrs.trackChange) {
66+
rowPos = pos;
67+
rowNode = node;
68+
return false;
69+
}
70+
return true;
71+
});
72+
expect(rowPos).not.toBeNull();
73+
74+
const tr = editor.state.tr.setNodeMarkup(rowPos, undefined, { ...rowNode.attrs, trackChange: null });
75+
editor.view.dispatch(tr);
76+
77+
const pluginState = CommentsPluginKey.getState(editor.state);
78+
expect(pluginState.trackedChanges['OP1']).toBeUndefined();
79+
});
5380
});

packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -555,10 +555,16 @@ export const CommentsPlugin = Extension.create({
555555
isBlockLevel: true,
556556
};
557557
}
558-
// Merge: keep existing inline entries, add block-level entries.
559-
// Block-level keys (operationId / row-id) shouldn't collide with
560-
// inline ones (mark-id), but if they ever do, inline wins.
561-
pluginState.trackedChanges = { ...blockTracked, ...pluginState.trackedChanges };
558+
// Drop stale block-level entries from the previous state first.
559+
// getBlockTrackedChanges is the source of truth for block-level
560+
// tracking, so anything no longer in the doc (accepted/rejected)
561+
// must not leak back via the merge. Inline entries are owned by
562+
// handleTrackedChangeTransaction and are kept as-is.
563+
const previousInline = {};
564+
for (const [k, v] of Object.entries(pluginState.trackedChanges || {})) {
565+
if (!v?.isBlockLevel) previousInline[k] = v;
566+
}
567+
pluginState.trackedChanges = { ...previousInline, ...blockTracked };
562568
}
563569

564570
// Check for changes in the actively selected comment

packages/super-editor/src/editors/v1/extensions/track-changes/blockTrackedChangeAttr.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,14 @@ export const blockTrackedChangeAttrSpec = {
2020
},
2121
renderDOM: (attrs) => {
2222
const tc = attrs?.trackChange;
23-
return tc ? { 'data-track-change': tc.kind } : {};
23+
if (!tc) return {};
24+
const out = { 'data-track-change': tc.kind };
25+
// Emit id + operationId so HTML round-trips (clipboard, getHTML/setContent,
26+
// collaboration patches) preserve enough to resolve the change via
27+
// getBlockTrackedChanges and accept/reject it.
28+
if (tc.id) out['data-track-change-id'] = tc.id;
29+
if (tc.operationId) out['data-track-change-operation'] = tc.operationId;
30+
return out;
2431
},
2532
},
2633
};

packages/super-editor/src/editors/v1/extensions/track-changes/blockTrackedChangeAttr.test.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,36 @@ describe('blockTrackedChangeAttrSpec', () => {
2626
expect(blockTrackedChangeAttrSpec.trackChange.parseDOM(el)).toBeNull();
2727
});
2828

29-
it('renderDOM emits data-track-change when set; nothing when null', () => {
29+
it('renderDOM emits kind, id, and operationId when present', () => {
3030
expect(
3131
blockTrackedChangeAttrSpec.trackChange.renderDOM({
3232
trackChange: { kind: 'insert', id: 'r1', operationId: 'op1' },
3333
}),
34-
).toEqual({ 'data-track-change': 'insert' });
34+
).toEqual({
35+
'data-track-change': 'insert',
36+
'data-track-change-id': 'r1',
37+
'data-track-change-operation': 'op1',
38+
});
39+
});
40+
41+
it('renderDOM omits optional id / operationId when missing', () => {
42+
expect(
43+
blockTrackedChangeAttrSpec.trackChange.renderDOM({
44+
trackChange: { kind: 'delete' },
45+
}),
46+
).toEqual({ 'data-track-change': 'delete' });
47+
});
48+
49+
it('renderDOM emits nothing when trackChange is null or absent', () => {
3550
expect(blockTrackedChangeAttrSpec.trackChange.renderDOM({ trackChange: null })).toEqual({});
3651
expect(blockTrackedChangeAttrSpec.trackChange.renderDOM({})).toEqual({});
3752
});
53+
54+
it('renderDOM and parseDOM round-trip preserve id and operationId', () => {
55+
const original = { kind: 'delete', id: 'row-42', operationId: 'op-99' };
56+
const rendered = blockTrackedChangeAttrSpec.trackChange.renderDOM({ trackChange: original });
57+
const el = document.createElement('tr');
58+
for (const [k, v] of Object.entries(rendered)) el.setAttribute(k, v);
59+
expect(blockTrackedChangeAttrSpec.trackChange.parseDOM(el)).toEqual(original);
60+
});
3861
});

0 commit comments

Comments
 (0)