@beyondwork/docx-react-component 1.0.85 → 1.0.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +49 -0
  3. package/src/api/v3/ui/chrome-composition.ts +2 -11
  4. package/src/api/v3/ui/chrome.ts +6 -8
  5. package/src/index.ts +5 -0
  6. package/src/io/export/serialize-main-document.ts +215 -6
  7. package/src/io/ooxml/parse-drawing.ts +15 -1
  8. package/src/io/ooxml/parse-fields.ts +410 -12
  9. package/src/model/canonical-document.ts +177 -2
  10. package/src/model/layout/page-layout-snapshot.ts +2 -0
  11. package/src/model/layout/runtime-page-graph-types.ts +6 -0
  12. package/src/preservation/store.ts +4 -5
  13. package/src/runtime/document-outline.ts +80 -0
  14. package/src/runtime/document-runtime.ts +338 -13
  15. package/src/runtime/formatting/field/page-number-format.ts +49 -0
  16. package/src/runtime/formatting/field/resolver.ts +61 -40
  17. package/src/runtime/layout/layout-engine-instance.ts +18 -1
  18. package/src/runtime/layout/layout-engine-version.ts +19 -1
  19. package/src/runtime/layout/measurement-backend-canvas.ts +21 -9
  20. package/src/runtime/layout/measurement-backend-empirical.ts +18 -4
  21. package/src/runtime/layout/page-graph.ts +13 -2
  22. package/src/runtime/layout/paginated-layout-engine.ts +440 -117
  23. package/src/runtime/layout/project-block-fragments.ts +87 -4
  24. package/src/runtime/layout/resolve-page-fields.ts +8 -5
  25. package/src/runtime/layout/table-row-split.ts +97 -23
  26. package/src/runtime/surface-projection.ts +227 -27
  27. package/src/shell/session-bootstrap.ts +6 -1
  28. package/src/ui/WordReviewEditor.tsx +112 -33
  29. package/src/ui/editor-command-bag.ts +4 -0
  30. package/src/ui/editor-shell-view.tsx +1 -0
  31. package/src/ui/editor-surface-controller.tsx +1 -0
  32. package/src/ui/headless/revision-decoration-model.ts +11 -13
  33. package/src/ui-tailwind/chrome/editor-action-registry.ts +7 -26
  34. package/src/ui-tailwind/chrome/responsive-chrome.ts +2 -2
  35. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +27 -0
  36. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +6 -2
  37. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +57 -6
  38. package/src/ui-tailwind/editor-surface/page-slice-util.ts +17 -0
  39. package/src/ui-tailwind/editor-surface/perf-probe.ts +5 -0
  40. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +34 -0
  41. package/src/ui-tailwind/editor-surface/pm-decorations.ts +146 -20
  42. package/src/ui-tailwind/editor-surface/pm-position-map.ts +8 -2
  43. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +41 -44
  44. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +1 -1
  45. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +8 -3
  46. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -0
  47. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +75 -31
  48. package/src/ui-tailwind/review/tw-workflow-tab.tsx +159 -8
  49. package/src/ui-tailwind/review-workspace/types.ts +4 -0
  50. package/src/ui-tailwind/review-workspace/use-review-rail-state.ts +1 -1
  51. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +8 -10
  52. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +21 -17
  53. package/src/ui-tailwind/tw-review-workspace.tsx +27 -2
@@ -3,13 +3,47 @@ import { Plugin } from "prosemirror-state";
3
3
  export interface ContextualInteractionCallbacks {
4
4
  onCommentActivated?: (commentId: string) => void;
5
5
  onRevisionActivated?: (revisionId: string) => void;
6
+ onRevisionHovered?: (revisionId: string | null) => void;
7
+ }
8
+
9
+ function findRevisionId(target: EventTarget | null): string | null {
10
+ const element = target as HTMLElement | null;
11
+ return element?.closest?.("[data-revision-id]")?.getAttribute("data-revision-id") ?? null;
6
12
  }
7
13
 
8
14
  export function createContextualInteractionPlugin(
9
15
  callbacks: ContextualInteractionCallbacks,
10
16
  ): Plugin {
17
+ let hoveredRevisionId: string | null = null;
18
+
11
19
  return new Plugin({
12
20
  props: {
21
+ handleDOMEvents: {
22
+ mouseover(_view, event) {
23
+ const revisionId = findRevisionId(event.target);
24
+ if (!revisionId || revisionId === hoveredRevisionId) {
25
+ return false;
26
+ }
27
+ hoveredRevisionId = revisionId;
28
+ callbacks.onRevisionHovered?.(revisionId);
29
+ return false;
30
+ },
31
+ mouseout(_view, event) {
32
+ const revisionId = findRevisionId(event.target);
33
+ if (!revisionId) {
34
+ return false;
35
+ }
36
+ const relatedRevisionId = findRevisionId(event.relatedTarget);
37
+ if (relatedRevisionId === revisionId) {
38
+ return false;
39
+ }
40
+ if (hoveredRevisionId === revisionId) {
41
+ hoveredRevisionId = null;
42
+ callbacks.onRevisionHovered?.(null);
43
+ }
44
+ return false;
45
+ },
46
+ },
13
47
  handleClick(_view, _pos, event) {
14
48
  const target = event.target as HTMLElement | null;
15
49
  const commentId = target?.closest?.("[data-comment-id]")?.getAttribute("data-comment-id");
@@ -4,10 +4,12 @@ import type { CommentDecorationModel } from "../../ui/headless/comment-decoratio
4
4
  import { getCommentHighlightClass, type MarkupDisplay } from "../../ui/headless/comment-decoration-model";
5
5
  import type {
6
6
  RevisionDecorationModel,
7
+ RevisionDecorationEntry,
7
8
  RevisionDisplayFlags,
8
9
  } from "../../ui/headless/revision-decoration-model";
9
10
  import {
10
11
  buildClassFromRevisionDisplay,
12
+ getAuthorColor,
11
13
  getRevisionHighlightClass,
12
14
  } from "../../ui/headless/revision-decoration-model";
13
15
  import type {
@@ -46,6 +48,106 @@ type RailDecorationSpec = {
46
48
  attrs: Record<string, string>;
47
49
  };
48
50
 
51
+ function sanitizeRevisionAuthorColor(raw: unknown): string | null {
52
+ if (typeof raw !== "string") return null;
53
+ const value = raw.trim();
54
+ if (/^var\(--color-chart-categorical-[1-8]\)$/.test(value)) return value;
55
+ return sanitizeHostCssColor(value);
56
+ }
57
+
58
+ function resolveRevisionAuthorColor(
59
+ rev: RevisionDecorationEntry,
60
+ display?: RevisionDisplayFlags,
61
+ ): string | undefined {
62
+ return sanitizeRevisionAuthorColor(display?.authorColor) ?? getAuthorColor(rev.authorId);
63
+ }
64
+
65
+ function buildRevisionAuthorStyle(
66
+ kind: RevisionDecorationEntry["kind"],
67
+ authorColor: string | undefined,
68
+ ): string | undefined {
69
+ if (!authorColor) return undefined;
70
+
71
+ const backgroundStrength =
72
+ kind === "deletion" ? "8%" : kind === "insertion" ? "10%" : "9%";
73
+ return [
74
+ `--wre-revision-author: ${authorColor}`,
75
+ "color: var(--wre-revision-author)",
76
+ `background-color: color-mix(in srgb, var(--wre-revision-author) ${backgroundStrength}, transparent)`,
77
+ `text-decoration-color: var(--wre-revision-author)`,
78
+ "text-decoration-thickness: 2px",
79
+ "text-underline-offset: 2px",
80
+ "box-decoration-break: clone",
81
+ "-webkit-box-decoration-break: clone",
82
+ ].join("; ");
83
+ }
84
+
85
+ function labelRevisionKind(kind: RevisionDecorationEntry["kind"]): string {
86
+ switch (kind) {
87
+ case "insertion":
88
+ return "Insertion";
89
+ case "deletion":
90
+ return "Deletion";
91
+ case "formatting":
92
+ return "Formatting change";
93
+ case "move":
94
+ return "Move";
95
+ case "property-change":
96
+ return "Property change";
97
+ }
98
+ }
99
+
100
+ function buildRevisionInlineAttrs(
101
+ rev: RevisionDecorationEntry,
102
+ className: string,
103
+ display?: RevisionDisplayFlags,
104
+ ): Record<string, string> {
105
+ const attrs: Record<string, string> = {
106
+ class: className,
107
+ "data-revision-id": rev.revisionId,
108
+ "data-revision-kind": rev.kind,
109
+ };
110
+ if (rev.authorId) {
111
+ attrs["data-revision-author-id"] = rev.authorId;
112
+ attrs.title = `${labelRevisionKind(rev.kind)} by ${rev.authorId}`;
113
+ }
114
+ if (rev.authorPaletteIndex !== undefined) {
115
+ attrs["data-revision-author-index"] = String(rev.authorPaletteIndex);
116
+ }
117
+
118
+ const style = buildRevisionAuthorStyle(
119
+ rev.kind,
120
+ resolveRevisionAuthorColor(rev, display),
121
+ );
122
+ if (style) {
123
+ attrs.style = style;
124
+ }
125
+ return attrs;
126
+ }
127
+
128
+ function buildRevisionBoundaryAttrs(
129
+ rev: RevisionDecorationEntry,
130
+ display?: RevisionDisplayFlags,
131
+ ): Record<string, string> {
132
+ const attrs: Record<string, string> = {
133
+ class: "text-insert font-semibold",
134
+ "data-revision-id": rev.revisionId,
135
+ "data-revision-kind": rev.kind,
136
+ };
137
+ if (rev.authorId) {
138
+ attrs["data-revision-author-id"] = rev.authorId;
139
+ attrs.title = `${labelRevisionKind(rev.kind)} by ${rev.authorId}`;
140
+ }
141
+ if (rev.authorPaletteIndex !== undefined) {
142
+ attrs["data-revision-author-index"] = String(rev.authorPaletteIndex);
143
+ }
144
+ const authorColor = resolveRevisionAuthorColor(rev, display);
145
+ if (authorColor) {
146
+ attrs.style = `color: ${authorColor}`;
147
+ }
148
+ return attrs;
149
+ }
150
+
49
151
  /**
50
152
  * Validate and normalize a host-supplied CSS color before interpolating it
51
153
  * into an inline-style string. Accepts only the narrow subset a
@@ -466,6 +568,7 @@ export function buildDecorations(
466
568
  Decoration.inline(cleanPmFrom, cleanPmTo, {
467
569
  class: "hidden",
468
570
  "data-revision-id": rev.revisionId,
571
+ "data-revision-kind": rev.kind,
469
572
  }),
470
573
  );
471
574
  revisionCount += 1;
@@ -480,17 +583,28 @@ export function buildDecorations(
480
583
  // Suggestions styling is always shown regardless of showTrackedChanges toggle.
481
584
  if (suggestionsEnabled) {
482
585
  if (rev.kind === "insertion") {
586
+ const insertionClass =
587
+ buildClassFromRevisionDisplay(revDisplayFlags) ||
588
+ getRevisionHighlightClass(revisionModel, rev.from, rev.to, "all");
483
589
  decorations.push(
484
- Decoration.inline(pmFrom, pmTo, {
485
- class: "text-insert",
486
- "data-revision-id": rev.revisionId,
487
- }),
590
+ Decoration.inline(
591
+ pmFrom,
592
+ pmTo,
593
+ buildRevisionInlineAttrs(rev, insertionClass, revDisplayFlags),
594
+ ),
488
595
  );
489
596
  decorations.push(
490
597
  Decoration.widget(pmFrom, () => {
491
598
  const el = document.createElement("span");
492
599
  el.textContent = "[";
493
- el.className = "text-insert";
600
+ const attrs = buildRevisionBoundaryAttrs(rev, revDisplayFlags);
601
+ for (const [name, value] of Object.entries(attrs)) {
602
+ if (name === "class") {
603
+ el.className = value;
604
+ } else {
605
+ el.setAttribute(name, value);
606
+ }
607
+ }
494
608
  el.setAttribute("contenteditable", "false");
495
609
  return el;
496
610
  }, { side: -1, key: `${rev.revisionId}-open` }),
@@ -499,30 +613,41 @@ export function buildDecorations(
499
613
  Decoration.widget(pmTo, () => {
500
614
  const el = document.createElement("span");
501
615
  el.textContent = "]";
502
- el.className = "text-insert";
616
+ const attrs = buildRevisionBoundaryAttrs(rev, revDisplayFlags);
617
+ for (const [name, value] of Object.entries(attrs)) {
618
+ if (name === "class") {
619
+ el.className = value;
620
+ } else {
621
+ el.setAttribute(name, value);
622
+ }
623
+ }
503
624
  el.setAttribute("contenteditable", "false");
504
625
  return el;
505
626
  }, { side: 1, key: `${rev.revisionId}-close` }),
506
627
  );
507
628
  revisionCount += 1;
508
629
  } else if (rev.kind === "deletion") {
630
+ const deletionClass =
631
+ buildClassFromRevisionDisplay(revDisplayFlags) ||
632
+ getRevisionHighlightClass(revisionModel, rev.from, rev.to, "all");
509
633
  decorations.push(
510
- Decoration.inline(pmFrom, pmTo, {
511
- class: "text-danger line-through decoration-danger/80 decoration-1",
512
- "data-revision-id": rev.revisionId,
513
- }),
634
+ Decoration.inline(
635
+ pmFrom,
636
+ pmTo,
637
+ buildRevisionInlineAttrs(rev, deletionClass, revDisplayFlags),
638
+ ),
514
639
  );
515
640
  revisionCount += 1;
516
641
  } else if (rev.kind === "property-change" || rev.kind === "formatting") {
517
642
  const propertyChangeClass =
518
643
  buildClassFromRevisionDisplay(revDisplayFlags) ||
519
- "underline decoration-accent/70 decoration-dotted decoration-1 underline-offset-2";
644
+ "rounded-[2px] bg-accent-soft/70 px-[1px] underline decoration-accent/80 decoration-dotted decoration-2 underline-offset-2";
520
645
  decorations.push(
521
- Decoration.inline(pmFrom, pmTo, {
522
- class: propertyChangeClass,
523
- "data-revision-id": rev.revisionId,
524
- "data-revision-kind": rev.kind,
525
- }),
646
+ Decoration.inline(
647
+ pmFrom,
648
+ pmTo,
649
+ buildRevisionInlineAttrs(rev, propertyChangeClass, revDisplayFlags),
650
+ ),
526
651
  );
527
652
  revisionCount += 1;
528
653
  }
@@ -547,10 +672,11 @@ export function buildDecorations(
547
672
  if (!cls) continue;
548
673
 
549
674
  decorations.push(
550
- Decoration.inline(pmFrom, pmTo, {
551
- class: cls,
552
- "data-revision-id": rev.revisionId,
553
- }),
675
+ Decoration.inline(
676
+ pmFrom,
677
+ pmTo,
678
+ buildRevisionInlineAttrs(rev, cls, displayFlags),
679
+ ),
554
680
  );
555
681
  revisionCount += 1;
556
682
  }
@@ -87,13 +87,19 @@ function walkBlocks(
87
87
  break;
88
88
  }
89
89
  case "opaque_block": {
90
+ const placeholderSize =
91
+ block.state === "placeholder-culled" &&
92
+ typeof block.placeholderSize === "number" &&
93
+ Number.isFinite(block.placeholderSize)
94
+ ? Math.max(1, block.placeholderSize)
95
+ : 1;
90
96
  entries.push({
91
97
  runtimeStart: block.from,
92
98
  pmStart: nextPmCursor,
93
99
  runtimeEnd: block.to,
94
- pmEnd: nextPmCursor + 1,
100
+ pmEnd: nextPmCursor + placeholderSize,
95
101
  });
96
- nextPmCursor += 1;
102
+ nextPmCursor += placeholderSize;
97
103
  break;
98
104
  }
99
105
  case "sdt_block": {
@@ -51,6 +51,7 @@ import {
51
51
  } from "./pm-command-bridge";
52
52
  import { buildDecorations } from "./pm-decorations";
53
53
  import { buildPageBreakDecorations } from "./pm-page-break-decorations";
54
+ import { findBlockIndexRangeForPage } from "./page-slice-util.ts";
54
55
  import { DecorationSet } from "prosemirror-view";
55
56
  import type { WordReviewEditorLayoutFacet } from "../../api/public-types.ts";
56
57
  import { buildPagePreviewMaps } from "../../api/public-types";
@@ -63,7 +64,10 @@ import {
63
64
  } from "./perf-probe";
64
65
  import { buildPositionMap, type PositionMap } from "./pm-position-map";
65
66
  import { createLocalEditSessionState } from "./local-edit-session-state";
66
- import { createFastTextEditLane } from "./fast-text-edit-lane";
67
+ import {
68
+ createFastTextEditLane,
69
+ getTextCommandRefreshClass,
70
+ } from "./fast-text-edit-lane";
67
71
  import { createPredictedTxGate } from "./predicted-tx-gate";
68
72
  import { replaceStatePreservingPosition } from "./preserve-position";
69
73
  import {
@@ -148,9 +152,12 @@ function buildPageBreakDecorationsFromProps(
148
152
  : undefined;
149
153
 
150
154
  // L7 Phase 2 Task 2.2.4a — compute per-page block-index ranges from the
151
- // render frame's page offsets + the surface blocks list. Each block has a
152
- // `from`/`to` offset; we find the first and last block whose offset range
153
- // falls within each page's [startOffset, nextPage.startOffset) window.
155
+ // render frame's page offsets + the surface blocks list. Each block has a
156
+ // `from`/`to` offset; a block belongs to every page whose offset window it
157
+ // overlaps. That matters for large tables/objects that can straddle a page
158
+ // boundary: matching only `block.from` would omit the active block from the
159
+ // page marker, cull it on the next viewport refresh, and remap the caret to
160
+ // the wrong PM position during snapshot replacement.
154
161
  // This map is passed into `buildPageBreakDecorations` so the chrome widgets
155
162
  // carry `data-page-first-block-index` / `data-page-last-block-index`
156
163
  // attributes needed by `useVisibleBlockRange`.
@@ -160,24 +167,9 @@ function buildPageBreakDecorationsFromProps(
160
167
  for (let pi = 0; pi < frame.pages.length; pi++) {
161
168
  const page = frame.pages[pi]!;
162
169
  if (page.page.isBlankFiller) continue;
163
- const pageStart = page.page.startOffset;
164
- const pageEnd =
165
- pi + 1 < frame.pages.length
166
- ? frame.pages[pi + 1]!.page.startOffset
167
- : Infinity;
168
- let first = -1;
169
- let last = -1;
170
- for (let bi = 0; bi < surfaceBlocks.length; bi++) {
171
- const block = surfaceBlocks[bi]!;
172
- const blockFrom = block.from; // from is required on all SurfaceBlockSnapshot variants
173
- // Block belongs to this page if its start falls within the page's offset window.
174
- if (blockFrom >= pageStart && blockFrom < pageEnd) {
175
- if (first === -1) first = bi;
176
- last = bi;
177
- }
178
- }
179
- if (first !== -1) {
180
- blockIndexRangeByPageIndex.set(page.page.pageIndex, { first, last });
170
+ const range = findBlockIndexRangeForPage(surfaceBlocks, page.page);
171
+ if (range) {
172
+ blockIndexRangeByPageIndex.set(page.page.pageIndex, range);
181
173
  }
182
174
  }
183
175
  }
@@ -290,6 +282,7 @@ export interface TwProseMirrorSurfaceProps {
290
282
  }) => void;
291
283
  onCommentActivated?: (commentId: string) => void;
292
284
  onRevisionActivated?: (revisionId: string) => void;
285
+ onRevisionHovered?: (revisionId: string | null) => void;
293
286
  onSelectionToolbarAnchorChange?: (anchor: SelectionToolbarAnchor | null) => void;
294
287
  mediaPreviews?: Record<string, MediaPreviewDescriptor>;
295
288
  workflowScopes?: readonly WorkflowScope[];
@@ -441,7 +434,7 @@ export const TwProseMirrorSurface = forwardRef<
441
434
  const suppressSelectionEchoRef = useRef(false);
442
435
  const sessionRef = useRef<import("./local-edit-session-state").LocalEditSessionState | null>(null);
443
436
  const laneRef = useRef<import("./fast-text-edit-lane").FastTextEditLane | null>(null);
444
- const equivalentAckKeyRef = useRef<string | null>(null);
437
+ const equivalentAckLedgerRef = useRef<Map<string, string>>(new Map());
445
438
  const selectionToolbarFrameRef = useRef<number | null>(null);
446
439
  const lastSelectionToolbarMeasurementRef = useRef<{
447
440
  key: string | null;
@@ -653,10 +646,11 @@ export const TwProseMirrorSurface = forwardRef<
653
646
  createContextualInteractionPlugin({
654
647
  onCommentActivated: (commentId) => props.onCommentActivated?.(commentId),
655
648
  onRevisionActivated: (revisionId) => props.onRevisionActivated?.(revisionId),
649
+ onRevisionHovered: (revisionId) => props.onRevisionHovered?.(revisionId),
656
650
  }),
657
651
  createSearchPlugin(),
658
652
  ];
659
- }, [props.awareness, props.onCommentActivated, props.onRevisionActivated]);
653
+ }, [props.awareness, props.onCommentActivated, props.onRevisionActivated, props.onRevisionHovered]);
660
654
 
661
655
  const applyDecorationProps = useCallback(
662
656
  (view: EditorView, positionMap: PositionMap): void => {
@@ -771,6 +765,7 @@ export const TwProseMirrorSurface = forwardRef<
771
765
  useEffect(() => {
772
766
  if (!props.dispatchRuntimeCommand || !sessionRef.current) {
773
767
  laneRef.current = null;
768
+ equivalentAckLedgerRef.current.clear();
774
769
  return;
775
770
  }
776
771
  // Wave 1 Slice E1/E2 — lane observability.
@@ -815,28 +810,27 @@ export const TwProseMirrorSurface = forwardRef<
815
810
  toRuntime,
816
811
  );
817
812
  },
818
- onEquivalentAck: () => {
819
- // INVARIANT: this marker is set only by onEquivalentAck, which the
820
- // runtime invokes synchronously from dispatchRuntimeCommand. The
821
- // rebuild effect's short-circuit (search for "Predicted-lane
822
- // short-circuit" below) reads it during the same React render cycle
823
- // that the predicted dispatch triggered. If the runtime ack ever
824
- // becomes async (microtask, animation frame, network round-trip),
825
- // this marker will be stale by the time the rebuild effect runs and
826
- // the short-circuit must be replaced with a
827
- // `pendingEquivalentAckOpIds: Set<string>` ledger keyed by opId.
828
- equivalentAckKeyRef.current = documentBuildKeyRef.current;
813
+ onEquivalentAck: (ack) => {
814
+ if (
815
+ ack.opId &&
816
+ ack.newRevisionToken &&
817
+ getTextCommandRefreshClass(ack) === "local-text-equivalent"
818
+ ) {
819
+ equivalentAckLedgerRef.current.set(ack.newRevisionToken, ack.opId);
820
+ return;
821
+ }
822
+ equivalentAckLedgerRef.current.clear();
829
823
  },
830
824
  onAdjustedAck: () => {
831
825
  // Adjusted path: allow the rebuild effect to run (it will call
832
826
  // view.updateState with the canonical snapshot).
833
- equivalentAckKeyRef.current = null;
827
+ equivalentAckLedgerRef.current.clear();
834
828
  },
835
829
  onRejectedAck: () => {
836
- equivalentAckKeyRef.current = null;
830
+ equivalentAckLedgerRef.current.clear();
837
831
  },
838
832
  onStructuralDivergence: () => {
839
- equivalentAckKeyRef.current = null;
833
+ equivalentAckLedgerRef.current.clear();
840
834
  },
841
835
  });
842
836
  }, [props.dispatchRuntimeCommand, scopeTagRegistry]);
@@ -852,13 +846,16 @@ export const TwProseMirrorSurface = forwardRef<
852
846
  // ack, the PM doc already matches the canonical snapshot. Update tracking
853
847
  // refs and decorations without rebuilding the PM state.
854
848
  //
855
- // INVARIANT: reads `equivalentAckKeyRef.current` set by `onEquivalentAck`
856
- // above. Depends on the runtime ack being synchronous so the marker is
857
- // already in place when this effect runs after the predicted dispatch.
858
- // See the comment at `onEquivalentAck` for the async-ack migration path.
849
+ // INVARIANT: equivalent acks are tracked by revision token and op id, not
850
+ // by the previous build key. This keeps the short-circuit valid if the ack
851
+ // and render snapshot stop arriving in the same synchronous React pass.
852
+ const equivalentAckOpId =
853
+ sessionRef.current && !sessionRef.current.hasPending()
854
+ ? equivalentAckLedgerRef.current.get(snapshot.revisionToken)
855
+ : undefined;
859
856
  if (
860
857
  viewRef.current &&
861
- equivalentAckKeyRef.current !== null &&
858
+ equivalentAckOpId !== undefined &&
862
859
  sessionRef.current &&
863
860
  !sessionRef.current.hasPending() &&
864
861
  sessionRef.current.getBaseRevisionToken() === snapshot.revisionToken
@@ -868,7 +865,7 @@ export const TwProseMirrorSurface = forwardRef<
868
865
  positionMapRef.current = buildPositionMap(surface);
869
866
  documentBuildKeyRef.current = documentBuildKey;
870
867
  applyDecorationProps(viewRef.current, positionMapRef.current);
871
- equivalentAckKeyRef.current = null;
868
+ equivalentAckLedgerRef.current.delete(snapshot.revisionToken);
872
869
  if (pendingTypingProbeRef.current) {
873
870
  finishPerfProbe(pendingTypingProbeRef.current);
874
871
  pendingTypingProbeRef.current = null;
@@ -172,7 +172,7 @@ function resolveBlockRangeFromOffsetSpan(input: {
172
172
  const block = blocks[index];
173
173
  if (!block) continue;
174
174
  if (block.from >= endOffset) break;
175
- if (block.from >= startOffset && block.from < endOffset) {
175
+ if (block.from < endOffset && block.to > startOffset) {
176
176
  if (first < 0) first = index;
177
177
  last = index;
178
178
  }
@@ -153,6 +153,7 @@ function CommentThreadCard(props: {
153
153
  }, [presentation]);
154
154
  const leadEntry = thread.entries[0];
155
155
  const isDraftThread = thread.status === "open" && thread.entryCount === 1 && isEmptyCommentBody(leadEntry?.body);
156
+ const isLinkedRevisionThread = thread.linkedRevisionId != null;
156
157
  const isOwnComment = props.currentUserId != null && leadEntry?.authorId === props.currentUserId;
157
158
  const canEdit = isOwnComment && thread.status === "open" && props.onEditBody != null;
158
159
  const hasNoBody = isEmptyCommentBody(leadEntry?.body);
@@ -205,6 +206,7 @@ function CommentThreadCard(props: {
205
206
  {formatCommentDate(thread.createdAt)}
206
207
  </span>
207
208
  <span className="flex-1" />
209
+ {isLinkedRevisionThread ? <StatusBadge label="tracked change" tone="revision" /> : null}
208
210
  {isDraftThread ? <StatusBadge label="draft" tone="draft" /> : null}
209
211
  {thread.status === "resolved" ? <StatusBadge label="resolved" tone="resolved" /> : null}
210
212
  </div>
@@ -222,7 +224,9 @@ function CommentThreadCard(props: {
222
224
  body={leadEntry?.body ?? ""}
223
225
  autoFocus={isActive && hasNoBody}
224
226
  onSave={(newBody) => props.onEditBody?.(thread.commentId, newBody)}
225
- label={isDraftThread ? "New comment" : undefined}
227
+ label={isDraftThread
228
+ ? (isLinkedRevisionThread ? "Tracked change discussion" : "New comment")
229
+ : undefined}
226
230
  />
227
231
  ) : presentation ? (
228
232
  <CommentMarkdownRenderer
@@ -247,7 +251,7 @@ function CommentThreadCard(props: {
247
251
  props.onOpenComment?.(thread);
248
252
  }}
249
253
  >
250
- New comment
254
+ {isLinkedRevisionThread ? "Tracked change discussion" : "New comment"}
251
255
  </p>
252
256
  ) : null}
253
257
 
@@ -494,11 +498,12 @@ function formatCommentDate(raw: string): string {
494
498
  }
495
499
  }
496
500
 
497
- function StatusBadge(props: { label: string; tone: "resolved" | "detached" | "draft" }) {
501
+ function StatusBadge(props: { label: string; tone: "resolved" | "detached" | "draft" | "revision" }) {
498
502
  const styles: Record<string, string> = {
499
503
  resolved: "text-insert bg-insert-soft",
500
504
  detached: "text-comment bg-warning-soft",
501
505
  draft: "text-secondary bg-subtle",
506
+ revision: "text-accent bg-accent-soft",
502
507
  };
503
508
  return (
504
509
  <span
@@ -111,6 +111,7 @@ export interface TwReviewRailProps {
111
111
  onAddReply?: (commentId: string, body: string) => void;
112
112
  onEditBody?: (commentId: string, body: string) => void;
113
113
  onOpenRevision?: (revision: TrackedChangeEntrySnapshot) => void;
114
+ onReplyToRevision?: (revision: TrackedChangeEntrySnapshot) => void;
114
115
  onAcceptRevision?: (revisionId: string) => void;
115
116
  onRejectRevision?: (revisionId: string) => void;
116
117
  onAcceptAllChanges?: () => void;
@@ -285,6 +286,7 @@ export function TwReviewRail(props: TwReviewRailProps) {
285
286
  markupDisplay={props.markupDisplay}
286
287
  activeRevisionId={props.activeRevisionId}
287
288
  onOpenRevision={props.onOpenRevision}
289
+ onReplyToRevision={props.onReplyToRevision}
288
290
  onAcceptRevision={props.onAcceptRevision}
289
291
  onRejectRevision={props.onRejectRevision}
290
292
  onAcceptAllChanges={props.onAcceptAllChanges}