@beyondwork/docx-react-component 1.0.86 → 1.0.88

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 (45) 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 +580 -40
  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 +8 -0
  29. package/src/ui/editor-surface-controller.tsx +1 -0
  30. package/src/ui/headless/revision-decoration-model.ts +11 -13
  31. package/src/ui-tailwind/chrome/responsive-chrome.ts +2 -2
  32. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +27 -0
  33. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +57 -6
  34. package/src/ui-tailwind/editor-surface/page-slice-util.ts +17 -0
  35. package/src/ui-tailwind/editor-surface/perf-probe.ts +5 -0
  36. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +34 -0
  37. package/src/ui-tailwind/editor-surface/pm-decorations.ts +146 -20
  38. package/src/ui-tailwind/editor-surface/pm-position-map.ts +8 -2
  39. package/src/ui-tailwind/editor-surface/preserve-position.ts +31 -6
  40. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +20 -5
  41. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +92 -50
  42. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +1 -1
  43. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +32 -3
  44. package/src/ui-tailwind/review-workspace/use-review-rail-state.ts +1 -1
  45. package/src/ui-tailwind/tw-review-workspace.tsx +18 -0
@@ -204,18 +204,16 @@ export function buildClassFromRevisionDisplay(
204
204
 
205
205
  // Insertion underline (simple / all markup, insertion kind).
206
206
  if (display.insertionUnderline) {
207
- // Matches the underline visual used in the review-store path for
208
- // "simple-markup" insertions; keeps the two paths producing the
209
- // same class bundle so a mid-flight migration does not produce a
210
- // visual diff.
211
- parts.push(
212
- "underline decoration-insert/60 decoration-1 underline-offset-2 text-primary",
213
- );
207
+ parts.push(display.markupMode === "all"
208
+ ? "rounded-[2px] bg-insert-soft/35 px-[1px] text-primary underline decoration-insert decoration-2 underline-offset-2"
209
+ : "rounded-[2px] px-[1px] text-primary underline decoration-insert decoration-2 underline-offset-2");
214
210
  }
215
211
 
216
212
  // Strikethrough (deletion kind, simple or all markup).
217
213
  if (display.strikethrough) {
218
- parts.push("text-danger line-through decoration-danger/80 decoration-1");
214
+ parts.push(display.markupMode === "all"
215
+ ? "rounded-[2px] bg-delete-soft/35 px-[1px] text-danger line-through decoration-danger decoration-2"
216
+ : "text-danger line-through decoration-danger decoration-2");
219
217
  }
220
218
 
221
219
  // De-emphasize (e.g. inactive revision in all-markup; reviewer
@@ -234,7 +232,7 @@ export function buildClassFromRevisionDisplay(
234
232
  (display.kind === "formatting" || display.kind === "property-change")
235
233
  ) {
236
234
  parts.push(
237
- "underline decoration-accent/70 decoration-dotted decoration-1 underline-offset-2",
235
+ "rounded-[2px] bg-accent-soft/70 px-[1px] underline decoration-accent/80 decoration-dotted decoration-2 underline-offset-2",
238
236
  );
239
237
  }
240
238
 
@@ -277,18 +275,18 @@ export function getRevisionHighlightClass(
277
275
  return "";
278
276
  case "simple-markup":
279
277
  if (state.hasInsertions) {
280
- return `underline decoration-insert/60 decoration-1 underline-offset-2 text-primary${activeRing}`;
278
+ return `rounded-[2px] px-[1px] text-primary underline decoration-insert decoration-2 underline-offset-2${activeRing}`;
281
279
  }
282
280
  if (state.hasDeletions) {
283
- return `text-secondary line-through decoration-danger/70 decoration-1${activeRing}`;
281
+ return `text-danger line-through decoration-danger decoration-2${activeRing}`;
284
282
  }
285
283
  return activeRing;
286
284
  case "all-markup":
287
285
  if (state.hasInsertions) {
288
- return `text-primary bg-insert-soft/80 ring-1 ring-insert/20${activeRing}`;
286
+ return `rounded-[2px] bg-insert-soft/35 px-[1px] text-primary underline decoration-insert decoration-2 underline-offset-2${activeRing}`;
289
287
  }
290
288
  if (state.hasDeletions) {
291
- return `text-danger line-through decoration-danger/80 decoration-1 bg-delete-soft/70${activeRing}`;
289
+ return `rounded-[2px] bg-delete-soft/35 px-[1px] text-danger line-through decoration-danger decoration-2${activeRing}`;
292
290
  }
293
291
  return activeRing;
294
292
  }
@@ -17,11 +17,11 @@ export function isNarrowChromeViewport(viewportWidth?: number): boolean {
17
17
  return typeof viewportWidth === "number" && viewportWidth <= NARROW_CHROME_MAX_WIDTH;
18
18
  }
19
19
 
20
- export function getInitialReviewRailOpen(input: {
20
+ export function getInitialReviewRailOpen(_input: {
21
21
  viewportWidth?: number;
22
22
  reviewRailAvailable: boolean;
23
23
  }): boolean {
24
- return input.reviewRailAvailable && !isNarrowChromeViewport(input.viewportWidth);
24
+ return false;
25
25
  }
26
26
 
27
27
  export function resolveResponsiveChromeState(
@@ -123,6 +123,7 @@ export function TwAlertBanner(
123
123
  // 3. Workflow blocked — host policy refuses a command, per reasons.
124
124
  if (workflowBlockedReasons.length > 0) {
125
125
  const firstReason = workflowBlockedReasons[0]!;
126
+ const hint = getWorkflowBlockedHint(firstReason);
126
127
  return renderBanner({
127
128
  severity: "warning",
128
129
  icon: (
@@ -131,6 +132,7 @@ export function TwAlertBanner(
131
132
  message: (
132
133
  <>
133
134
  {firstReason.message}
135
+ {hint ? <span className="opacity-80"> {hint}</span> : null}
134
136
  {workflowBlockedReasons.length > 1
135
137
  ? ` (+${workflowBlockedReasons.length - 1} more)`
136
138
  : ""}
@@ -161,3 +163,28 @@ export function TwAlertBanner(
161
163
 
162
164
  return null;
163
165
  }
166
+
167
+ function getWorkflowBlockedHint(
168
+ reason: WorkflowBlockedCommandReason,
169
+ ): string | null {
170
+ switch (reason.code) {
171
+ case "suggesting_unsupported":
172
+ return "Switch to Edit for this command, or insert plain text.";
173
+ case "workflow_comment_only":
174
+ return "Add a comment, or use an editing scope.";
175
+ case "outside_workflow_scope":
176
+ return "Move into an editable workflow scope.";
177
+ case "workflow_view_only":
178
+ case "document_viewing_mode":
179
+ case "document_read_only":
180
+ return "Open an editable copy or request edit access.";
181
+ case "workflow_preserve_only":
182
+ case "workflow_blocked_import":
183
+ case "protected_range":
184
+ case "unsupported_surface":
185
+ return "Use detail for the safe path.";
186
+ case "workflow_round_locked":
187
+ return "Wait for the round to unlock or request approval.";
188
+ }
189
+ return null;
190
+ }
@@ -1,7 +1,10 @@
1
1
  import type { Transaction } from "prosemirror-state";
2
2
  import type { EditorView } from "prosemirror-view";
3
3
 
4
- import type { TextCommandAck } from "../../api/public-types.ts";
4
+ import type {
5
+ TextCommandAck,
6
+ TextCommandRefreshClass,
7
+ } from "../../api/public-types.ts";
5
8
  import type {
6
9
  LocalEditSessionState,
7
10
  PendingOp,
@@ -78,7 +81,11 @@ export interface FastTextEditLaneOptions {
78
81
  /** Optional probe hooks for perf instrumentation. */
79
82
  probe?: {
80
83
  markPredicted(opId: string): void;
81
- markReconciled(opId: string, kind: TextCommandAck["kind"]): void;
84
+ markReconciled(
85
+ opId: string,
86
+ kind: TextCommandAck["kind"],
87
+ refreshClass: TextCommandRefreshClass,
88
+ ): void;
82
89
  };
83
90
  }
84
91
 
@@ -96,6 +103,22 @@ function allocOpId(): string {
96
103
  return `op-${Date.now().toString(36)}-${nextOpIdCounter}`;
97
104
  }
98
105
 
106
+ export function getTextCommandRefreshClass(
107
+ ack: TextCommandAck,
108
+ ): TextCommandRefreshClass {
109
+ if (ack.refreshClass) return ack.refreshClass;
110
+ switch (ack.kind) {
111
+ case "equivalent":
112
+ return "local-text-equivalent";
113
+ case "adjusted":
114
+ return "surface-only";
115
+ case "rejected":
116
+ return "blocked";
117
+ case "structural-divergence":
118
+ return "full-projection";
119
+ }
120
+ }
121
+
99
122
  export function createFastTextEditLane(
100
123
  options: FastTextEditLaneOptions,
101
124
  ): FastTextEditLane {
@@ -136,9 +159,11 @@ export function createFastTextEditLane(
136
159
 
137
160
  if (options.shouldBailBeforePredict?.(intent, fromRuntime, toRuntime)) {
138
161
  const ack = options.dispatchRuntimeCommand(toRuntimeCommand(intent, opId));
162
+ const refreshClass = getTextCommandRefreshClass(ack);
139
163
  incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.bailBeforePredict);
140
- markLaneDebugReconciled(debugEntry, ack.kind, true);
141
- options.probe?.markReconciled(opId, ack.kind);
164
+ incrementRefreshClassCounter(refreshClass);
165
+ markLaneDebugReconciled(debugEntry, ack.kind, refreshClass, true);
166
+ options.probe?.markReconciled(opId, ack.kind, refreshClass);
142
167
  switch (ack.kind) {
143
168
  case "equivalent":
144
169
  options.session.advanceToRevision({
@@ -184,8 +209,10 @@ export function createFastTextEditLane(
184
209
  op.predictedSelectionHead = view.state.selection.head;
185
210
 
186
211
  const ack = options.dispatchRuntimeCommand(toRuntimeCommand(intent, opId));
187
- markLaneDebugReconciled(debugEntry, ack.kind, false);
188
- options.probe?.markReconciled(opId, ack.kind);
212
+ const refreshClass = getTextCommandRefreshClass(ack);
213
+ markLaneDebugReconciled(debugEntry, ack.kind, refreshClass, false);
214
+ incrementRefreshClassCounter(refreshClass);
215
+ options.probe?.markReconciled(opId, ack.kind, refreshClass);
189
216
 
190
217
  switch (ack.kind) {
191
218
  case "equivalent":
@@ -284,6 +311,8 @@ interface LaneDebugEntry {
284
311
  toRuntime: number;
285
312
  /** Dispatch → reconcile observation. Filled by `markLaneDebugReconciled`. */
286
313
  ackKind?: TextCommandAck["kind"];
314
+ /** Narrow refresh tier derived from the runtime ack. */
315
+ refreshClass?: TextCommandRefreshClass;
287
316
  /** Wall-clock ms between `pushLaneDebug` and `markLaneDebugReconciled`. */
288
317
  reconcileMs?: number;
289
318
  /** Whether the lane short-circuited to dispatch-only (no predicted TX). */
@@ -336,10 +365,12 @@ function pushLaneDebug(
336
365
  function markLaneDebugReconciled(
337
366
  entry: LaneDebugEntry | null,
338
367
  ackKind: TextCommandAck["kind"],
368
+ refreshClass: TextCommandRefreshClass,
339
369
  bailed: boolean,
340
370
  ): void {
341
371
  if (!entry) return;
342
372
  entry.ackKind = ackKind;
373
+ entry.refreshClass = refreshClass;
343
374
  entry.bailed = bailed;
344
375
  const now =
345
376
  typeof performance !== "undefined" && typeof performance.now === "function"
@@ -348,6 +379,26 @@ function markLaneDebugReconciled(
348
379
  entry.reconcileMs = now - entry.startedAtMs;
349
380
  }
350
381
 
382
+ function incrementRefreshClassCounter(refreshClass: TextCommandRefreshClass): void {
383
+ switch (refreshClass) {
384
+ case "selection-only":
385
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.refreshSelectionOnly);
386
+ return;
387
+ case "local-text-equivalent":
388
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.refreshLocalTextEquivalent);
389
+ return;
390
+ case "surface-only":
391
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.refreshSurfaceOnly);
392
+ return;
393
+ case "full-projection":
394
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.refreshFullProjection);
395
+ return;
396
+ case "blocked":
397
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.refreshBlocked);
398
+ return;
399
+ }
400
+ }
401
+
351
402
  function buildTxCompat(
352
403
  view: EditorView,
353
404
  _intent: PredictedIntent,
@@ -13,3 +13,20 @@ export function sliceBlocksForPage(
13
13
  (b) => b.from < page.endOffset && b.to > page.startOffset,
14
14
  );
15
15
  }
16
+
17
+ export function findBlockIndexRangeForPage(
18
+ blocks: readonly SurfaceBlockSnapshot[],
19
+ page: Pick<DocumentPageSnapshot, "startOffset" | "endOffset">,
20
+ ): { first: number; last: number } | null {
21
+ if (page.endOffset <= page.startOffset) return null;
22
+ let first = -1;
23
+ let last = -1;
24
+ for (let index = 0; index < blocks.length; index += 1) {
25
+ const block = blocks[index]!;
26
+ if (block.from < page.endOffset && block.to > page.startOffset) {
27
+ if (first === -1) first = index;
28
+ last = index;
29
+ }
30
+ }
31
+ return first === -1 ? null : { first, last };
32
+ }
@@ -41,6 +41,11 @@ export const PREDICTED_LANE_COUNTERS = {
41
41
  rollback: "predictions.rollback",
42
42
  structuralDivergence: "predictions.structuralDivergence",
43
43
  bailBeforePredict: "predictions.bailBeforePredict",
44
+ refreshSelectionOnly: "predictions.refresh.selectionOnly",
45
+ refreshLocalTextEquivalent: "predictions.refresh.localTextEquivalent",
46
+ refreshSurfaceOnly: "predictions.refresh.surfaceOnly",
47
+ refreshFullProjection: "predictions.refresh.fullProjection",
48
+ refreshBlocked: "predictions.refresh.blocked",
44
49
  } as const;
45
50
 
46
51
  export interface PerfProbeSample {
@@ -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": {
@@ -29,7 +29,7 @@ import type { EditorView } from "prosemirror-view";
29
29
  import type { GeometryFacet } from "../../api/public-types.ts";
30
30
  import {
31
31
  findScrollAnchor,
32
- restoreScrollAnchor,
32
+ resolveScrollTopForAnchor,
33
33
  type ScrollAnchor,
34
34
  } from "./scroll-anchor.ts";
35
35
 
@@ -56,6 +56,13 @@ export interface PreservePositionOptions {
56
56
  * element directly to avoid the DOM lookup.
57
57
  */
58
58
  scrollRoot?: HTMLElement | null;
59
+ /**
60
+ * Optional safety bound for edit-path restores. When set, restores whose
61
+ * target differs from the captured `scrollTop` by more than this many CSS
62
+ * pixels are refused. This keeps scroll preservation narrow for typing
63
+ * rebuilds while still allowing small same-story geometry shifts.
64
+ */
65
+ maxScrollDeltaPx?: number;
59
66
  }
60
67
 
61
68
  export interface PreservedPosition {
@@ -102,11 +109,29 @@ export function capturePosition(
102
109
  export function restorePosition(
103
110
  captured: PreservedPosition,
104
111
  options: PreservePositionOptions,
105
- ): void {
106
- if (!captured.scrollRoot || !captured.anchor) return;
107
- restoreScrollAnchor(captured.scrollRoot, captured.anchor, {
108
- geometryFacet: options.geometryFacet,
109
- });
112
+ ): boolean {
113
+ if (!captured.scrollRoot || !captured.anchor) return false;
114
+ const targetScrollTop = resolveScrollTopForAnchor(
115
+ captured.scrollRoot,
116
+ captured.anchor,
117
+ {
118
+ geometryFacet: options.geometryFacet,
119
+ },
120
+ );
121
+ if (targetScrollTop === null || !Number.isFinite(targetScrollTop)) {
122
+ return false;
123
+ }
124
+ if (targetScrollTop < 0) {
125
+ return false;
126
+ }
127
+ if (
128
+ options.maxScrollDeltaPx !== undefined &&
129
+ Math.abs(targetScrollTop - captured.scrollTop) > options.maxScrollDeltaPx
130
+ ) {
131
+ return false;
132
+ }
133
+ captured.scrollRoot.scrollTop = targetScrollTop;
134
+ return true;
110
135
  }
111
136
 
112
137
  /**