@beyondwork/docx-react-component 1.0.78 → 1.0.80

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.78",
4
+ "version": "1.0.80",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -1211,6 +1211,18 @@ export type SurfaceInlineSegment =
1211
1211
  * is enforced by `test/runtime/formatting/production-boundary.test.ts`.
1212
1212
  */
1213
1213
  revisionDisplay?: {
1214
+ /**
1215
+ * Identity of the attached revision. Render consumers emit
1216
+ * `data-revision-id` and route sidebar scroll-to-revision off
1217
+ * this field rather than re-deriving from the review store.
1218
+ */
1219
+ revisionId: string;
1220
+ /**
1221
+ * The revision's kind. Mirrors `RevisionRecord.kind` on the
1222
+ * canonical document. Render consumers branch on this (e.g.
1223
+ * widget-bracket placement in suggestions mode).
1224
+ */
1225
+ kind: "insertion" | "deletion" | "formatting" | "move" | "property-change";
1214
1226
  markupMode: "clean" | "simple" | "all";
1215
1227
  hidden?: boolean;
1216
1228
  strikethrough?: boolean;
@@ -1566,6 +1578,22 @@ export type SurfaceBlockSnapshot =
1566
1578
  * See CLAUDE.md (lane status table)
1567
1579
  */
1568
1580
  placeholderSize?: number;
1581
+ /**
1582
+ * Visual height of the block the placeholder stands in for, in twips,
1583
+ * as computed by the L04 paginator against the fully-realized surface.
1584
+ * When present, the PM placeholder emits a fixed-height `<div>` so the
1585
+ * scrollable canvas matches what the realized block will occupy once
1586
+ * it re-enters the viewport — eliminating the "paragraphs jump around
1587
+ * pagination gaps" flicker that plagued culled regions rendered as
1588
+ * single-line ZWSP stubs.
1589
+ *
1590
+ * Populated by `DocumentRuntime.buildRenderSnapshot()` after each
1591
+ * pagination pass; surface-projection itself does not set it (L03 has
1592
+ * no layout data). Consumers that build a surface without a runtime
1593
+ * (tests, plain-text preview) safely leave it `undefined` and fall
1594
+ * back to the `min-height: 20px` behaviour.
1595
+ */
1596
+ placeholderHeightTwips?: number;
1569
1597
  state: "locked-preserve-only" | "placeholder-culled";
1570
1598
  };
1571
1599
 
@@ -2487,9 +2515,40 @@ export interface AddScopeParams {
2487
2515
  }
2488
2516
 
2489
2517
  export interface AddScopeResult {
2518
+ /**
2519
+ * Minted scope id. Empty string (`""`) iff the marker plant failed —
2520
+ * in that case `anchor.kind === "detached"` + `plantStatus.planted`
2521
+ * is `false`. Pre-2026-04-24 a plant failure silently returned a
2522
+ * non-empty scopeId that later resolved to `scope-not-resolvable`;
2523
+ * callers must now check `scopeId.length > 0` (or equivalently
2524
+ * `plantStatus?.planted !== false`) before using the id.
2525
+ */
2490
2526
  scopeId: string;
2491
- /** Range anchor derived from the just-inserted marker positions. */
2527
+ /** Range anchor derived from the just-inserted marker positions, or a detached placeholder on plant failure. */
2492
2528
  anchor: EditorAnchorProjection;
2529
+ /**
2530
+ * Structured diagnostic for the marker-plant attempt. Omitted on the
2531
+ * happy path for back-compat; populated with `planted: false` + a
2532
+ * typed reason when the plant was refused (non-paragraph target,
2533
+ * out-of-bounds, empty-document). Cross-paragraph ranges plant
2534
+ * successfully as of 2026-04-24 — see the multi-paragraph-scopes
2535
+ * slice. Callers that previously assumed every returned `scopeId`
2536
+ * was live should migrate to checking this field.
2537
+ */
2538
+ plantStatus?: {
2539
+ readonly planted: false;
2540
+ readonly reason:
2541
+ | "non-paragraph-target"
2542
+ | "range-out-of-bounds"
2543
+ | "empty-document";
2544
+ readonly requestedFrom: number;
2545
+ readonly requestedTo: number;
2546
+ /** Present on `non-paragraph-target`. */
2547
+ readonly blockIndex?: number;
2548
+ readonly blockKind?: string;
2549
+ /** Present on `range-out-of-bounds`. */
2550
+ readonly storyLength?: number;
2551
+ };
2493
2552
  }
2494
2553
 
2495
2554
  export interface ExportDocxOptions {
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * @endStateApi v3 — `ai.resolve` family.
3
3
  *
4
- * Slice 3 of refactor/08 graduates `resolveReference` to
5
- * `live-with-adapter` for the FOUR DETERMINISTIC hint kinds: `scope-id`,
6
- * `semantic-path`, `offset`, and `range`. Offset / range resolution now
7
- * honors precise nested-kind ranges (fields, table rows, table cells)
8
- * via the scope-compiler's specificity tie-breaker — see
9
- * `docs/architecture/08-semantic-scope-compiler.md` §"Resolve-reference
10
- * precision" for the specificity table.
4
+ * Slice 3 of refactor/08 graduated `resolveReference` to
5
+ * `live-with-adapter`. Post-KI-P9 (2026-04-24) the reference union
6
+ * carries **three durable reference kinds**: `scope-id`, `semantic-path`,
7
+ * and `natural-language`. Positional queries (`offset` / `range`) were
8
+ * split into `queryScopeAtPosition(at)` / `queryScopeInRange(from, to)`
9
+ * one-shot APIs that return `ScopeHandle | null` directly — the
10
+ * type-system split prevents a positional hint from being cached or
11
+ * round-tripped as a reference across a mutation.
12
+ *
13
+ * Offset / range lookups still honor precise nested-kind ranges (fields,
14
+ * table rows, table cells) via the scope-compiler's specificity
15
+ * tie-breaker — see `docs/architecture/08-semantic-scope-compiler.md`
16
+ * §"Resolve-reference precision" for the specificity table.
11
17
  *
12
18
  * The `natural-language` hint remains **partial by design**. The live
13
19
  * path is a deterministic substring matcher over:
@@ -160,13 +160,17 @@ export type CreateScopeFromAnchorResult =
160
160
  readonly reason:
161
161
  | "from-negative"
162
162
  | "to-less-than-from"
163
- | "range-exceeds-story-length";
163
+ | "range-exceeds-story-length"
164
+ | "non-paragraph-target"
165
+ | "empty-document";
164
166
  readonly from: number;
165
167
  readonly to: number;
166
168
  readonly storyLength: number;
169
+ readonly blockIndex?: number;
170
+ readonly blockKind?: string;
167
171
  /** Agent-actionable single-sentence explanation. Safe to surface to LLM tool replies as-is. */
168
172
  readonly message: string;
169
- /** Machine-routable next-step hint (`"clamp-from-to-zero"` / `"swap-from-and-to"` / `"clamp-to-to-storyLength-or-pick-a-different-range"`). */
173
+ /** Machine-routable next-step hint. */
170
174
  readonly nextStep: string;
171
175
  };
172
176
 
@@ -533,6 +537,12 @@ export function createWorkflowFamily(runtime: RuntimeApiHandle) {
533
537
  from: adapterResult.from,
534
538
  to: adapterResult.to,
535
539
  storyLength: adapterResult.storyLength,
540
+ ...(adapterResult.blockIndex !== undefined
541
+ ? { blockIndex: adapterResult.blockIndex }
542
+ : {}),
543
+ ...(adapterResult.blockKind !== undefined
544
+ ? { blockKind: adapterResult.blockKind }
545
+ : {}),
536
546
  message: adapterResult.message,
537
547
  nextStep: adapterResult.nextStep,
538
548
  };
@@ -7,18 +7,72 @@ import type {
7
7
  } from "../../model/canonical-document.ts";
8
8
  import type { CanonicalDocumentEnvelope } from "../state/editor-state.ts";
9
9
 
10
- export interface InsertScopeMarkersResult {
11
- document: CanonicalDocumentEnvelope;
12
- scopeId: string;
13
- }
10
+ /**
11
+ * Discriminated return — pre-2026-04-24 this helper silently returned
12
+ * the input document unchanged on every failure mode, which left
13
+ * callers holding a minted `scopeId` that referenced nothing in the
14
+ * canonical tree. `compileScopeById` later returned null and the v3
15
+ * surface emitted `scope-not-resolvable:<scopeId>` without the caller
16
+ * knowing whether the scope was ever live. Every non-`"planted"`
17
+ * status is now typed so callers can discriminate + surface
18
+ * agent-actionable recovery hints.
19
+ *
20
+ * Cross-paragraph ranges are first-class (planted) as of the
21
+ * 2026-04-24 multi-paragraph-scopes slice. The `cross-paragraph-range`
22
+ * failure variant no longer exists — markers now span paragraphs.
23
+ */
24
+ export type InsertScopeMarkersResult =
25
+ | {
26
+ readonly status: "planted";
27
+ readonly document: CanonicalDocumentEnvelope;
28
+ readonly scopeId: string;
29
+ /** Absolute span that got planted (after from/to normalization). */
30
+ readonly plantedRange: { readonly from: number; readonly to: number };
31
+ }
32
+ | {
33
+ readonly status: "non-paragraph-target";
34
+ readonly scopeId: string;
35
+ readonly from: number;
36
+ readonly to: number;
37
+ readonly blockIndex: number;
38
+ readonly blockKind: string;
39
+ }
40
+ | {
41
+ readonly status: "range-out-of-bounds";
42
+ readonly scopeId: string;
43
+ readonly from: number;
44
+ readonly to: number;
45
+ readonly storyLength: number;
46
+ }
47
+ | {
48
+ readonly status: "empty-document";
49
+ readonly scopeId: string;
50
+ readonly from: number;
51
+ readonly to: number;
52
+ };
14
53
 
15
54
  /**
16
- * Pure helper — returns a new CanonicalDocumentEnvelope with a pair of
17
- * scope-marker inline nodes inserted at the given position range.
55
+ * Pure helper — returns a discriminated result describing whether a
56
+ * pair of `scope_marker_start` / `scope_marker_end` inline nodes got
57
+ * inserted at `[from, to]`.
58
+ *
59
+ * Same-paragraph ranges insert both markers into the single owning
60
+ * paragraph. Cross-paragraph ranges place the start marker inside the
61
+ * paragraph containing `from` and the end marker inside the paragraph
62
+ * containing `to`; intermediate top-level blocks (paragraphs, tables,
63
+ * section breaks) are passed through unmodified and fall inside the
64
+ * scope by document order.
65
+ *
66
+ * Failure modes (all previously silent — pre-2026-04-24):
67
+ * - `non-paragraph-target` — either `from` or `to` lands inside a
68
+ * non-paragraph block (table / SDT / section_break). Marker
69
+ * scopes only plant inside paragraphs.
70
+ * - `range-out-of-bounds` — `to` exceeds the main story length.
71
+ * - `empty-document` — the document has no root / no children.
18
72
  *
19
- * Supports the common case: `from` and `to` land in the same top-level
20
- * paragraph. Cross-paragraph ranges are currently a no-op and return the
21
- * document unchanged multi-block scopes ship in a follow-up slice.
73
+ * Each failure variant names the scopeId the caller had minted but
74
+ * does NOT mutate the document callers treat it as a hard failure
75
+ * and must not register the scopeId on the overlay.
22
76
  */
23
77
  export function insertScopeMarkers(
24
78
  document: CanonicalDocumentEnvelope,
@@ -28,59 +82,166 @@ export function insertScopeMarkers(
28
82
  to: number;
29
83
  },
30
84
  ): InsertScopeMarkersResult {
31
- const { scopeId, from, to } = params;
85
+ const { scopeId } = params;
32
86
  const root = document.content as DocumentRootNode;
33
- if (!root || root.type !== "doc") return { document, scopeId };
87
+ const normalizedFrom = Math.min(params.from, params.to);
88
+ const normalizedTo = Math.max(params.from, params.to);
34
89
 
35
- const normalizedFrom = Math.min(from, to);
36
- const normalizedTo = Math.max(from, to);
90
+ if (!root || root.type !== "doc" || root.children.length === 0) {
91
+ return {
92
+ status: "empty-document",
93
+ scopeId,
94
+ from: normalizedFrom,
95
+ to: normalizedTo,
96
+ };
97
+ }
37
98
 
99
+ // Pre-pass — locate which block each of `from` and `to` resolves to,
100
+ // compute story length, and build per-paragraph slot envelopes.
101
+ // **Invariant:** the cursor arithmetic here MUST match
102
+ // `src/runtime/scopes/position-map.ts::computeBlockPositions`
103
+ // (same `inlineLength`, same `+1` per-block boundary). Layer 04
104
+ // cannot change one walker without the other or cross-paragraph
105
+ // marker insertion silently drifts vs. enumerated scope ranges.
106
+ type ParaSlot = { index: number; from: number; to: number };
107
+ const paraSlots: ParaSlot[] = [];
38
108
  let cursor = 0;
39
- let inserted = false;
40
- const children = root.children.map((block, blockIndex) => {
41
- if (inserted) return block;
42
- if (block.type !== "paragraph") {
43
- cursor += 1;
44
- if (blockIndex < root.children.length - 1) cursor += 1;
45
- return block;
46
- }
47
-
48
- const paragraphFrom = cursor;
49
- const paragraphLength = block.children.reduce(
50
- (total, child) => total + inlineLength(child as InlineNode),
51
- 0,
52
- );
53
- const paragraphTo = paragraphFrom + paragraphLength;
54
- cursor = paragraphTo + 1;
109
+ let fromBlockIndex = -1;
110
+ let fromBlockKind: string | null = null;
111
+ let toBlockIndex = -1;
112
+ let toBlockKind: string | null = null;
113
+ let storyLength = 0;
55
114
 
115
+ for (let i = 0; i < root.children.length; i += 1) {
116
+ const block = root.children[i]!;
117
+ const blockFrom = cursor;
118
+ let blockLength: number;
119
+ if (block.type === "paragraph") {
120
+ blockLength = block.children.reduce(
121
+ (total, child) => total + inlineLength(child as InlineNode),
122
+ 0,
123
+ );
124
+ paraSlots.push({ index: i, from: blockFrom, to: blockFrom + blockLength });
125
+ } else {
126
+ blockLength = 1;
127
+ }
128
+ const blockTo = blockFrom + blockLength;
56
129
  if (
57
- normalizedFrom < paragraphFrom ||
58
- normalizedTo > paragraphTo ||
59
- normalizedFrom > paragraphTo
130
+ fromBlockIndex === -1 &&
131
+ normalizedFrom >= blockFrom &&
132
+ normalizedFrom <= blockTo
60
133
  ) {
61
- return block;
134
+ fromBlockIndex = i;
135
+ fromBlockKind = block.type;
62
136
  }
137
+ if (
138
+ toBlockIndex === -1 &&
139
+ normalizedTo >= blockFrom &&
140
+ normalizedTo <= blockTo
141
+ ) {
142
+ toBlockIndex = i;
143
+ toBlockKind = block.type;
144
+ }
145
+ cursor = blockTo;
146
+ if (i < root.children.length - 1) cursor += 1;
147
+ storyLength = cursor;
148
+ }
63
149
 
64
- inserted = true;
65
- const startOffset = normalizedFrom - paragraphFrom;
66
- const endOffset = normalizedTo - paragraphFrom;
150
+ if (normalizedFrom < 0 || normalizedTo > storyLength) {
151
+ return {
152
+ status: "range-out-of-bounds",
153
+ scopeId,
154
+ from: normalizedFrom,
155
+ to: normalizedTo,
156
+ storyLength,
157
+ };
158
+ }
159
+ if (fromBlockIndex === -1 || toBlockIndex === -1) {
160
+ return {
161
+ status: "range-out-of-bounds",
162
+ scopeId,
163
+ from: normalizedFrom,
164
+ to: normalizedTo,
165
+ storyLength,
166
+ };
167
+ }
168
+ if (fromBlockKind !== "paragraph" || toBlockKind !== "paragraph") {
169
+ // Either endpoint lands on a non-paragraph top-level block
170
+ // (table / section_break / sdt). Report the first non-paragraph
171
+ // offender for diagnostic clarity.
172
+ const nonParaIndex =
173
+ fromBlockKind !== "paragraph" ? fromBlockIndex : toBlockIndex;
174
+ const nonParaKind =
175
+ fromBlockKind !== "paragraph" ? fromBlockKind : toBlockKind;
176
+ return {
177
+ status: "non-paragraph-target",
178
+ scopeId,
179
+ from: normalizedFrom,
180
+ to: normalizedTo,
181
+ blockIndex: nonParaIndex,
182
+ blockKind: nonParaKind ?? "unknown",
183
+ };
184
+ }
185
+
186
+ const startSlot = paraSlots.find((s) => s.index === fromBlockIndex)!;
187
+ const endSlot = paraSlots.find((s) => s.index === toBlockIndex)!;
188
+
189
+ // Same-paragraph fast path: inject both markers into one paragraph.
190
+ // Preserves all existing single-paragraph behaviour byte-for-byte
191
+ // (regression pin [C9]).
192
+ if (startSlot.index === endSlot.index) {
67
193
  const newChildren = injectMarkersIntoInlineList(
68
- block.children as InlineNode[],
194
+ (root.children[startSlot.index] as ParagraphNode).children as InlineNode[],
69
195
  scopeId,
70
- startOffset,
71
- endOffset,
196
+ normalizedFrom - startSlot.from,
197
+ normalizedTo - startSlot.from,
198
+ "both",
72
199
  );
73
- return { ...block, children: newChildren } as ParagraphNode;
74
- });
200
+ const children = root.children.map((block, i) =>
201
+ i === startSlot.index
202
+ ? ({ ...block, children: newChildren } as ParagraphNode)
203
+ : block,
204
+ );
205
+ return {
206
+ status: "planted",
207
+ document: { ...document, content: { ...root, children } },
208
+ scopeId,
209
+ plantedRange: { from: normalizedFrom, to: normalizedTo },
210
+ };
211
+ }
75
212
 
76
- if (!inserted) return { document, scopeId };
213
+ // Cross-paragraph path start marker goes in the start-bearing
214
+ // paragraph; end marker goes in the end-bearing paragraph;
215
+ // intermediate blocks pass through unchanged.
216
+ const children = root.children.map((block, i) => {
217
+ if (i === startSlot.index) {
218
+ const newChildren = injectMarkersIntoInlineList(
219
+ (block as ParagraphNode).children as InlineNode[],
220
+ scopeId,
221
+ normalizedFrom - startSlot.from,
222
+ Number.POSITIVE_INFINITY,
223
+ "start-only",
224
+ );
225
+ return { ...block, children: newChildren } as ParagraphNode;
226
+ }
227
+ if (i === endSlot.index) {
228
+ const newChildren = injectMarkersIntoInlineList(
229
+ (block as ParagraphNode).children as InlineNode[],
230
+ scopeId,
231
+ Number.NEGATIVE_INFINITY,
232
+ normalizedTo - endSlot.from,
233
+ "end-only",
234
+ );
235
+ return { ...block, children: newChildren } as ParagraphNode;
236
+ }
237
+ return block;
238
+ });
77
239
 
78
240
  return {
79
- document: {
80
- ...document,
81
- content: { ...root, children },
82
- },
241
+ status: "planted",
242
+ document: { ...document, content: { ...root, children } },
83
243
  scopeId,
244
+ plantedRange: { from: normalizedFrom, to: normalizedTo },
84
245
  };
85
246
  }
86
247
 
@@ -146,28 +307,28 @@ function injectMarkersIntoInlineList(
146
307
  scopeId: string,
147
308
  startOffset: number,
148
309
  endOffset: number,
310
+ mode: "both" | "start-only" | "end-only",
149
311
  ): InlineNode[] {
150
- const start: ScopeMarkerStartNode = {
151
- type: "scope_marker_start",
152
- scopeId,
153
- };
154
- const end: ScopeMarkerEndNode = {
155
- type: "scope_marker_end",
156
- scopeId,
157
- };
312
+ const start: ScopeMarkerStartNode = { type: "scope_marker_start", scopeId };
313
+ const end: ScopeMarkerEndNode = { type: "scope_marker_end", scopeId };
314
+
315
+ const needStart = mode === "both" || mode === "start-only";
316
+ const needEnd = mode === "both" || mode === "end-only";
158
317
 
159
318
  const output: InlineNode[] = [];
160
319
  let cursor = 0;
161
- let startEmitted = false;
162
- let endEmitted = false;
320
+ let startEmitted = !needStart;
321
+ let endEmitted = !needEnd;
163
322
 
164
323
  for (const node of inlines) {
165
324
  const length = inlineLength(node);
166
325
  const nodeStart = cursor;
167
326
  const nodeEnd = cursor + length;
168
327
 
169
- const startInside = !startEmitted && startOffset >= nodeStart && startOffset <= nodeEnd;
170
- const endInside = !endEmitted && endOffset >= nodeStart && endOffset <= nodeEnd;
328
+ const startInside =
329
+ needStart && !startEmitted && startOffset >= nodeStart && startOffset <= nodeEnd;
330
+ const endInside =
331
+ needEnd && !endEmitted && endOffset >= nodeStart && endOffset <= nodeEnd;
171
332
 
172
333
  if (!startInside && !endInside) {
173
334
  output.push(node);
@@ -175,10 +336,7 @@ function injectMarkersIntoInlineList(
175
336
  continue;
176
337
  }
177
338
 
178
- // Currently only text nodes support splitting for an internal cut.
179
339
  if (node.type !== "text") {
180
- // For non-text nodes, markers land at the node boundary closest to the
181
- // target offset — avoids mid-atom splits which would corrupt the node.
182
340
  if (startInside && !startEmitted && startOffset <= nodeStart) {
183
341
  output.push(start);
184
342
  startEmitted = true;
@@ -203,11 +361,7 @@ function injectMarkersIntoInlineList(
203
361
  const text = node.text;
204
362
  const chars = Array.from(text);
205
363
  const marks = node.marks;
206
- const pieces: {
207
- cut: number;
208
- emit: "start" | "end";
209
- }[] = [];
210
-
364
+ const pieces: { cut: number; emit: "start" | "end" }[] = [];
211
365
  if (startInside) pieces.push({ cut: startOffset - nodeStart, emit: "start" });
212
366
  if (endInside) pieces.push({ cut: endOffset - nodeStart, emit: "end" });
213
367
  pieces.sort((a, b) => a.cut - b.cut || (a.emit === "start" ? -1 : 1));
@@ -243,12 +397,11 @@ function injectMarkersIntoInlineList(
243
397
  cursor = nodeEnd;
244
398
  }
245
399
 
246
- // Append markers that were at the very end of the paragraph.
247
- if (!startEmitted) {
400
+ if (needStart && !startEmitted) {
248
401
  output.push(start);
249
402
  startEmitted = true;
250
403
  }
251
- if (!endEmitted) {
404
+ if (needEnd && !endEmitted) {
252
405
  output.push(end);
253
406
  endEmitted = true;
254
407
  }
@@ -57,6 +57,7 @@ import type {
57
57
  RestoreResult,
58
58
  ReviewWorkSnapshot,
59
59
  RuntimeContextAnalyticsQuery,
60
+ EditorSurfaceSnapshot,
60
61
  RuntimeContextAnalyticsSnapshot,
61
62
  RuntimeRenderSnapshot,
62
63
  ScopeTagTouch,
@@ -1561,17 +1562,91 @@ export function createDocumentRuntime(
1561
1562
  });
1562
1563
  recordPerfSample("snapshot.surface");
1563
1564
  incrementInvalidationCounter("runtime.snapshot.surfaceMisses");
1565
+ // Viewport-cull flicker fix — enrich `placeholder-culled` opaque
1566
+ // blocks with the block's known rendered height (twips) so the PM
1567
+ // placeholder emits a fixed-height `<div>` sized to match what the
1568
+ // realized block will occupy once it re-enters the viewport. Without
1569
+ // this, culled stubs render at `min-height: 20px` regardless of their
1570
+ // real visual size; scrolling into a culled region inflates every
1571
+ // block to its real height in succession and drags content below
1572
+ // the scroll pointer (the "paragraphs jump around pagination gaps"
1573
+ // flicker).
1574
+ //
1575
+ // The heights come from L04's page graph, which is already computed
1576
+ // on a FULLY-REALIZED surface inside `layout-engine-instance.ts` —
1577
+ // i.e. pagination is independent of the viewport cull, so every
1578
+ // blockId has an authoritative height regardless of whether its
1579
+ // surface block is real or a placeholder. `getBlockHeightsTwips()`
1580
+ // sums per-block fragments and caches per `graph.revision`.
1581
+ //
1582
+ // No-op on pre-pagination calls (engine not yet ready → empty map)
1583
+ // and on the inert facet.
1584
+ const enrichedSnapshot = enrichCulledPlaceholdersWithHeights(snapshot);
1564
1585
  cachedSurface = {
1565
1586
  revisionToken: state.revisionToken,
1566
1587
  activeStoryKey,
1567
1588
  viewportRangesKey,
1568
- snapshot,
1589
+ snapshot: enrichedSnapshot,
1569
1590
  };
1570
1591
  // Keep the scroll-path fingerprint in lockstep so a subsequent
1571
1592
  // `maybeRefreshSurfaceForViewport` sees the freshly-built snapshot
1572
1593
  // and short-circuits instead of paying a redundant projection.
1573
1594
  cachedSurfaceFingerprint = `${state.revisionToken}|${activeStoryKey}|${viewportRangesKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
1574
- return snapshot;
1595
+ return enrichedSnapshot;
1596
+ }
1597
+
1598
+ function enrichCulledPlaceholdersWithHeights(
1599
+ snapshot: EditorSurfaceSnapshot,
1600
+ ): EditorSurfaceSnapshot {
1601
+ let heights: ReadonlyMap<string, number>;
1602
+ try {
1603
+ heights = layoutFacet.getBlockHeightsTwips();
1604
+ } catch {
1605
+ return snapshot;
1606
+ }
1607
+ if (heights.size === 0) return snapshot;
1608
+ let changed = false;
1609
+ const enrichedBlocks = snapshot.blocks.map((block) => {
1610
+ if (
1611
+ block.kind !== "opaque_block" ||
1612
+ block.state !== "placeholder-culled"
1613
+ ) {
1614
+ return block;
1615
+ }
1616
+ // The culled placeholder's blockId is `placeholder-culled-<index>`;
1617
+ // the underlying ParagraphNode kept its own id which is NOT carried
1618
+ // on the placeholder. Instead, identify the real block by its
1619
+ // `from` offset — surface-projection guarantees `from` on the
1620
+ // placeholder mirrors the real block's `from`, and fragment records
1621
+ // on the page graph record runtime offsets.
1622
+ const realBlockIdFromOffset = resolveBlockIdFromRuntimeOffset(
1623
+ layoutFacet,
1624
+ block.from,
1625
+ );
1626
+ if (!realBlockIdFromOffset) return block;
1627
+ const heightTwips = heights.get(realBlockIdFromOffset);
1628
+ if (typeof heightTwips !== "number" || heightTwips <= 0) return block;
1629
+ changed = true;
1630
+ return { ...block, placeholderHeightTwips: heightTwips };
1631
+ });
1632
+ if (!changed) return snapshot;
1633
+ return { ...snapshot, blocks: enrichedBlocks };
1634
+ }
1635
+
1636
+ function resolveBlockIdFromRuntimeOffset(
1637
+ facet: WordReviewEditorLayoutFacet,
1638
+ runtimeOffset: number,
1639
+ ): string | null {
1640
+ // `getFragmentForOffset` exists on the facet; use it to recover the
1641
+ // real blockId for a given offset without having to walk all
1642
+ // fragments. Returns the fragment whose `[from, to)` contains the
1643
+ // offset, or null.
1644
+ try {
1645
+ const frag = facet.getFragmentForOffset?.(runtimeOffset);
1646
+ return frag?.blockId ?? null;
1647
+ } catch {
1648
+ return null;
1649
+ }
1575
1650
  }
1576
1651
 
1577
1652
  function getCachedFieldSnapshot(document: CanonicalDocumentEnvelope): FieldSnapshot {
@@ -110,6 +110,22 @@ export interface EffectiveFieldDisplay {
110
110
  * visual vocabulary (CSS class, inline style, decoration).
111
111
  */
112
112
  export interface RevisionDisplayFlags {
113
+ /**
114
+ * The attached revision's id. Enables render consumers to emit
115
+ * `data-revision-id` + route sidebar scroll-to-revision off the
116
+ * authoritative L03 flags rather than re-deriving from the review-store
117
+ * side channel. Present whenever `input.revision !== undefined` drove
118
+ * flag emission.
119
+ */
120
+ readonly revisionId: string;
121
+ /**
122
+ * The revision's kind. Mirrors `RevisionRecord.kind` from the canonical
123
+ * document (`src/model/canonical-document.ts`). Render consumers use
124
+ * this to branch on insertion/deletion/move/property-change variants
125
+ * (e.g. widget-bracket placement in suggestions mode) without reading
126
+ * the review store.
127
+ */
128
+ readonly kind: "insertion" | "deletion" | "formatting" | "move" | "property-change";
113
129
  readonly markupMode: "clean" | "simple" | "all";
114
130
  /** Hide the run entirely (e.g. open deletion in "clean" mode). */
115
131
  readonly hidden?: boolean;