@beyondwork/docx-react-component 1.0.43 → 1.0.45

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 (48) hide show
  1. package/README.md +17 -0
  2. package/package.json +44 -32
  3. package/src/api/public-types.ts +139 -3
  4. package/src/core/commands/formatting-commands.ts +7 -1
  5. package/src/core/commands/index.ts +27 -2
  6. package/src/core/commands/text-commands.ts +59 -0
  7. package/src/core/selection/review-anchors.ts +131 -21
  8. package/src/index.ts +16 -1
  9. package/src/io/chart-preview-resolver.ts +281 -0
  10. package/src/io/docx-session.ts +21 -1
  11. package/src/io/export/build-app-properties-xml.ts +1 -1
  12. package/src/io/export/serialize-comments.ts +38 -9
  13. package/src/io/export/twip.ts +1 -1
  14. package/src/io/normalize/normalize-text.ts +33 -0
  15. package/src/io/ooxml/parse-comments.ts +0 -33
  16. package/src/io/ooxml/parse-complex-content.ts +14 -0
  17. package/src/io/ooxml/parse-main-document.ts +4 -0
  18. package/src/preservation/opaque-region.ts +5 -0
  19. package/src/review/store/comment-remapping.ts +2 -2
  20. package/src/runtime/document-runtime.ts +316 -25
  21. package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
  22. package/src/runtime/edit-dispatch/index.ts +2 -0
  23. package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
  24. package/src/runtime/editor-surface/capabilities.ts +411 -0
  25. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  26. package/src/runtime/layout/layout-engine-instance.ts +46 -0
  27. package/src/runtime/layout/layout-engine-version.ts +41 -0
  28. package/src/runtime/layout/public-facet.ts +30 -0
  29. package/src/runtime/prerender/cache-envelope.ts +29 -0
  30. package/src/runtime/prerender/cache-key.ts +66 -0
  31. package/src/runtime/prerender/font-fingerprint.ts +17 -0
  32. package/src/runtime/prerender/graph-canonicalize.ts +121 -0
  33. package/src/runtime/prerender/indexeddb-cache.ts +184 -0
  34. package/src/runtime/prerender/prerender-document.ts +145 -0
  35. package/src/runtime/render/block-fragment-projection.ts +2 -0
  36. package/src/runtime/selection/post-edit-validator.ts +77 -0
  37. package/src/runtime/surface-projection.ts +35 -2
  38. package/src/ui/WordReviewEditor.tsx +75 -192
  39. package/src/ui/editor-runtime-boundary.ts +5 -1
  40. package/src/ui/runtime-shortcut-dispatch.ts +28 -68
  41. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
  42. package/src/ui-tailwind/editor-surface/pm-schema.ts +18 -0
  43. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +23 -0
  44. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  45. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +38 -0
  46. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
  47. package/src/ui-tailwind/theme/editor-theme.css +47 -14
  48. package/src/ui-tailwind/tw-review-workspace.tsx +131 -29
@@ -30,7 +30,12 @@ import { resolvePageFieldDisplayText } from "../../runtime/layout/resolve-page-f
30
30
  export const PAGE_CHROME_DEFAULTS = {
31
31
  headerBandPx: 32,
32
32
  footerBandPx: 32,
33
- interGapPx: 24,
33
+ // L8 polish (2026-04-19): bumped from 24 → 48 so the workspace canvas
34
+ // between papers reads as a real break between discrete pages, not a
35
+ // seam inside one long paper. Phase D's discrete paper cards will
36
+ // re-tune once overlay-owned gaps ship; until then the widget spacer
37
+ // carries the full gap height.
38
+ interGapPx: 48,
34
39
  } as const;
35
40
 
36
41
  export function totalPageBreakGapPx(
@@ -73,6 +78,15 @@ export interface PageBreakDecorationInput {
73
78
  headerPreviewByPageId?: ReadonlyMap<string, string>;
74
79
  /** Same shape for footers. */
75
80
  footerPreviewByPageId?: ReadonlyMap<string, string>;
81
+ /**
82
+ * L7 Phase 2 Task 2.2.4a — optional per-page block-index range so the
83
+ * viewport-tracking hook can translate page visibility into block indices
84
+ * without re-walking the surface blocks. Keyed by pageIndex (0-based).
85
+ * When omitted, the chrome widgets are emitted without block-index
86
+ * attributes and the hook falls back to its ESTIMATED_BLOCKS_PER_PAGE
87
+ * heuristic.
88
+ */
89
+ blockIndexRangeByPageIndex?: ReadonlyMap<number, { first: number; last: number }>;
76
90
  }
77
91
 
78
92
  export function buildPageBreakDecorations(
@@ -88,6 +102,7 @@ export function buildPageBreakDecorations(
88
102
  const interGapPx = input.interGapPx ?? PAGE_CHROME_DEFAULTS.interGapPx;
89
103
 
90
104
  const decorations: Decoration[] = [];
105
+
91
106
  for (let i = 1; i < graph.pages.length; i += 1) {
92
107
  const prev = graph.pages[i - 1]!;
93
108
  const next = graph.pages[i]!;
@@ -106,6 +121,8 @@ export function buildPageBreakDecorations(
106
121
  const nextHeaderPreview =
107
122
  input.headerPreviewByPageId?.get(next.pageId) ?? "";
108
123
 
124
+ const nextBlockRange = input.blockIndexRangeByPageIndex?.get(next.pageIndex);
125
+
109
126
  decorations.push(
110
127
  Decoration.widget(
111
128
  pmOffset,
@@ -125,6 +142,8 @@ export function buildPageBreakDecorations(
125
142
  hasNextHeaderStory: Boolean(next.stories.header),
126
143
  prevFooterPreview,
127
144
  nextHeaderPreview,
145
+ nextPageFirstBlockIndex: nextBlockRange?.first ?? -1,
146
+ nextPageLastBlockIndex: nextBlockRange?.last ?? -1,
128
147
  }),
129
148
  {
130
149
  side: -1,
@@ -176,6 +195,16 @@ interface ChromeWidgetInput {
176
195
  hasNextHeaderStory: boolean;
177
196
  prevFooterPreview: string;
178
197
  nextHeaderPreview: string;
198
+ /**
199
+ * L7 Phase 2 Task 2.2.4a — inclusive first block index of the next page.
200
+ * -1 when block-index info was not supplied to the decoration builder.
201
+ */
202
+ nextPageFirstBlockIndex: number;
203
+ /**
204
+ * L7 Phase 2 Task 2.2.4a — inclusive last block index of the next page.
205
+ * -1 when block-index info was not supplied to the decoration builder.
206
+ */
207
+ nextPageLastBlockIndex: number;
179
208
  }
180
209
 
181
210
  // P14.c — cache the widget DOM by input identity. PM rebuilds the
@@ -205,6 +234,8 @@ function widgetCacheKey(input: ChromeWidgetInput): string {
205
234
  input.hasNextHeaderStory ? "1" : "0",
206
235
  input.prevFooterPreview,
207
236
  input.nextHeaderPreview,
237
+ input.nextPageFirstBlockIndex,
238
+ input.nextPageLastBlockIndex,
208
239
  ].join("\x1f");
209
240
  }
210
241
 
@@ -238,14 +269,27 @@ function buildChromeWidgetDomUncached(input: ChromeWidgetInput): HTMLElement {
238
269
  root.setAttribute("data-posture", input.posture);
239
270
  root.setAttribute("data-prev-page-id", input.prevPageId);
240
271
  root.setAttribute("data-next-page-id", input.nextPageId);
241
- root.setAttribute("data-prev-page-index", String(input.prevPageIndex));
242
- root.setAttribute("data-next-page-index", String(input.nextPageIndex));
272
+ // NOTE: data-prev-page-index and data-next-page-index were removed — they had
273
+ // no reader after data-page-frame was added (Task 2.2.4a). The page-id attrs
274
+ // above are still consumed by the chrome overlay (see line ~265 in this file).
243
275
  // P3.a: expose page-frame boundary markers so the page stack and tests
244
276
  // can enumerate pages without re-walking the render graph. Each widget
245
277
  // ends page N (`prev`) and starts page N+1 (`next`); the outer workspace
246
278
  // frame still accounts for the boundaries at both ends of the document.
247
279
  root.setAttribute("data-page-frame-end", input.prevPageId);
248
280
  root.setAttribute("data-page-frame-start", input.nextPageId);
281
+ // L7 Phase 2 Task 2.2.4a — per-page markers for the viewport-tracking hook
282
+ // (`useVisibleBlockRange`). Each boundary widget identifies the page that
283
+ // STARTS at this boundary (i.e. `nextPageIndex`). The hook's
284
+ // IntersectionObserver picks these up via `[data-page-frame]` to compute the
285
+ // visible block range. Block-index attributes are set only when the caller
286
+ // supplied a `blockIndexRangeByPageIndex` map (value ≥ 0); otherwise they
287
+ // are omitted so the hook's fallback estimate is used cleanly.
288
+ root.setAttribute("data-page-frame", String(input.nextPageIndex));
289
+ if (input.nextPageFirstBlockIndex >= 0) {
290
+ root.setAttribute("data-page-first-block-index", String(input.nextPageFirstBlockIndex));
291
+ root.setAttribute("data-page-last-block-index", String(input.nextPageLastBlockIndex));
292
+ }
249
293
  root.contentEditable = "false";
250
294
  root.setAttribute("aria-hidden", "false");
251
295
  root.style.display = "block";
@@ -286,175 +330,42 @@ function buildChromeWidgetDomUncached(input: ChromeWidgetInput): HTMLElement {
286
330
  return root;
287
331
  }
288
332
 
289
- // PAGE-MODE chrome: footer band of prev + visible gap + header band of next.
333
+ // L8 Phase B (2026-04-19): the page-posture widget is a **transparent
334
+ // spacer**. Inline band / gap / band chrome is retired — the real H/F
335
+ // bands are painted by `TwPageStackChromeLayer` as per-page overlays
336
+ // (P8.11). The spacer keeps the same total height as the retired stack
337
+ // so `TwPageStackOverlayLayer.measureWidgetsViaBoundingRect` continues to
338
+ // produce the same page-overlay rect math; L8 Phase D will take over
339
+ // painting the gap and reduce this to 0.
340
+ //
341
+ // L8 polish (2026-04-19): the spacer carries a subtle, borderless
342
+ // page-count label centered in the inter-page gap so the workspace
343
+ // canvas reads as "two papers with a page number between them" — no
344
+ // pill, no border, just small muted text.
290
345
  root.style.height = `${
291
346
  input.footerBandPx + input.interGapPx + input.headerBandPx
292
347
  }px`;
293
348
  root.style.position = "relative";
294
-
295
- const footer = buildBand({
296
- kind: "footer",
297
- pageId: input.prevPageId,
298
- pageIndex: input.prevPageIndex,
299
- pageLabel: input.prevPageLabel,
300
- bandPx: input.footerBandPx,
301
- position: "top",
302
- hasStory: input.hasPrevFooterStory,
303
- previewText: input.prevFooterPreview,
304
- });
305
- root.appendChild(footer);
306
-
307
- // P3.a: the inter-page gap is now a visible canvas strip that reads as
308
- // "the space between two papers", not a subtle gradient inside one white
309
- // page. The strip paints in the workspace canvas color (the same color
310
- // the page frames float on in page mode), with subtle drop/rise shadows
311
- // on either edge so the preceding footer reads as "bottom of page N's
312
- // paper" and the following header reads as "top of page N+1's paper".
313
- //
314
- // The visual goal: bringing the user closer to a Word-native perception
315
- // of distinct pages without requiring the PM editable tree to be split
316
- // into per-page subtrees (that lands in P3.b).
317
- const separator = document.createElement("div");
318
- separator.className = "wre-page-chrome-separator";
319
- separator.setAttribute("data-kind", "page-chrome-separator");
320
- separator.style.position = "absolute";
321
- separator.style.left = "0";
322
- separator.style.right = "0";
323
- separator.style.top = `${input.footerBandPx}px`;
324
- separator.style.height = `${input.interGapPx}px`;
325
- // Canvas color (same as the scroll root's bg-surface) so the strip reads
326
- // as "gap between two papers". Page mode and canvas mode both use the
327
- // same token so the UX remains consistent at any chrome preset.
328
- separator.style.backgroundColor = "var(--color-surface, #f1f5f9)";
329
- // Inner shadows on top/bottom: the previous footer's bottom edge gains
330
- // a subtle paper-edge shadow, and the next header's top edge likewise.
331
- // Inset shadows let us avoid touching the footer / header DOM while
332
- // keeping the shadows flush with the band borders.
333
- separator.style.boxShadow = [
334
- // Top edge — simulates the bottom shadow of page N's paper.
335
- "inset 0 1px 0 rgba(15, 23, 42, 0.06)",
336
- "inset 0 2px 3px -2px rgba(15, 23, 42, 0.12)",
337
- // Bottom edge — simulates the top shadow of page N+1's paper.
338
- "inset 0 -1px 0 rgba(15, 23, 42, 0.06)",
339
- "inset 0 -2px 3px -2px rgba(15, 23, 42, 0.12)",
340
- ].join(", ");
341
- root.appendChild(separator);
342
-
343
- const header = buildBand({
344
- kind: "header",
345
- pageId: input.nextPageId,
346
- pageIndex: input.nextPageIndex,
347
- pageLabel: input.nextPageLabel,
348
- bandPx: input.headerBandPx,
349
- position: "bottom",
350
- topOffsetPx: input.footerBandPx + input.interGapPx,
351
- hasStory: input.hasNextHeaderStory,
352
- previewText: input.nextHeaderPreview,
353
- });
354
- root.appendChild(header);
355
-
356
- return root;
357
- }
358
-
359
- function buildBand(input: {
360
- kind: "header" | "footer";
361
- pageId: string;
362
- pageIndex: number;
363
- pageLabel: string;
364
- bandPx: number;
365
- position: "top" | "bottom";
366
- topOffsetPx?: number;
367
- hasStory: boolean;
368
- previewText: string;
369
- }): HTMLElement {
370
- const band = document.createElement("div");
371
- band.className = `wre-page-chrome-band wre-page-chrome-band-${input.kind}`;
372
- band.setAttribute("data-band-kind", input.kind);
373
- band.setAttribute("data-page-id", input.pageId);
374
- band.setAttribute("data-page-index", String(input.pageIndex));
375
- band.style.position = "absolute";
376
- band.style.left = "0";
377
- band.style.right = "0";
378
- band.style.top = `${input.topOffsetPx ?? 0}px`;
379
- band.style.height = `${input.bandPx}px`;
380
- band.style.display = "flex";
381
- band.style.alignItems = "center";
382
- band.style.justifyContent = "space-between";
383
- band.style.padding = "0 16px";
384
- band.style.fontSize = "10px";
385
- band.style.letterSpacing = "0.12em";
386
- band.style.textTransform = "uppercase";
387
- band.style.color = "var(--color-text-tertiary, #6b7280)";
388
- band.style.backgroundColor =
389
- "var(--color-surface-subtle, rgba(0,0,0,0.02))";
390
- band.style.borderTop =
391
- input.kind === "header"
392
- ? "1px solid var(--color-border, rgba(0,0,0,0.08))"
393
- : "none";
394
- band.style.borderBottom =
395
- input.kind === "footer"
396
- ? "1px solid var(--color-border, rgba(0,0,0,0.08))"
397
- : "none";
398
- // Bands are interactive: double-click fires a custom event the shell
399
- // forwards to `runtime.openStory()`.
400
- band.style.pointerEvents = "auto";
401
- band.style.cursor = input.hasStory ? "pointer" : "default";
402
- band.title = input.hasStory
403
- ? `Double-click to edit ${input.kind}`
404
- : `No ${input.kind} defined for this page`;
349
+ root.style.pointerEvents = "none";
350
+ root.setAttribute("aria-hidden", "true");
405
351
 
406
352
  const label = document.createElement("span");
407
- label.className = "wre-page-chrome-band-label";
408
- if (input.previewText && input.previewText.trim().length > 0) {
409
- // Show the live content (with PAGE/NUMPAGES resolved) rather than the
410
- // static "Header" / "Footer" placeholder. Band is compact so truncate
411
- // at ~80 chars visually via CSS overflow; keep raw in textContent.
412
- label.textContent = input.previewText;
413
- label.style.textTransform = "none";
414
- label.style.letterSpacing = "0";
415
- label.style.fontSize = "11px";
416
- label.style.color = "var(--color-text-secondary, #374151)";
417
- label.style.overflow = "hidden";
418
- label.style.textOverflow = "ellipsis";
419
- label.style.whiteSpace = "nowrap";
420
- label.style.maxWidth = "70%";
421
- } else {
422
- label.textContent = input.kind === "header" ? "Header" : "Footer";
423
- }
424
- band.appendChild(label);
425
-
426
- const pageLabel = document.createElement("span");
427
- pageLabel.className = "wre-page-chrome-band-page";
428
- pageLabel.textContent = input.pageLabel;
429
- band.appendChild(pageLabel);
353
+ label.className = "wre-page-chrome-gap-label";
354
+ label.setAttribute("data-kind", "page-gap-label");
355
+ label.textContent = input.nextPageLabel;
356
+ label.style.position = "absolute";
357
+ label.style.left = "50%";
358
+ label.style.top = "50%";
359
+ label.style.transform = "translate(-50%, -50%)";
360
+ label.style.fontSize = "10px";
361
+ label.style.letterSpacing = "0.12em";
362
+ label.style.textTransform = "uppercase";
363
+ label.style.color = "var(--color-tertiary, #6b7280)";
364
+ // Intentionally no background, no border, no padding — the label floats
365
+ // transparently in the workspace-canvas gap.
366
+ root.appendChild(label);
430
367
 
431
- if (input.hasStory) {
432
- band.addEventListener("dblclick", (event) => {
433
- event.stopPropagation();
434
- event.preventDefault();
435
- const eventName =
436
- input.kind === "header"
437
- ? "wre-open-header-story-for-page"
438
- : "wre-open-footer-story-for-page";
439
- // Use the band's owning document's `CustomEvent` constructor so the
440
- // event passes through jsdom's instance-of check. In a real browser
441
- // `band.ownerDocument.defaultView.CustomEvent` is the same as the
442
- // global `CustomEvent`; in jsdom the two differ and the global one
443
- // fails `dispatchEvent`'s internal Event-type convert step.
444
- const view = band.ownerDocument?.defaultView as
445
- | (Window & typeof globalThis)
446
- | null;
447
- const Ctor = view?.CustomEvent ?? CustomEvent;
448
- band.dispatchEvent(
449
- new Ctor(eventName, {
450
- bubbles: true,
451
- detail: { pageIndex: input.pageIndex, pageId: input.pageId },
452
- }),
453
- );
454
- });
455
- }
456
-
457
- return band;
368
+ return root;
458
369
  }
459
370
 
460
371
  /**
@@ -160,9 +160,26 @@ export const editorSchema = new Schema({
160
160
  bidi: { default: null },
161
161
  pageBreakBefore: { default: null },
162
162
  hiddenTextOnly: { default: null },
163
+ placeholderCulled: { default: null },
164
+ blockId: { default: null },
163
165
  },
164
166
  parseDOM: [{ tag: "p" }],
165
167
  toDOM(node) {
168
+ // Viewport-culled placeholder paragraph — cheap size-preserving leaf.
169
+ if (node.attrs.placeholderCulled) {
170
+ return [
171
+ "div",
172
+ {
173
+ "data-node-type": "paragraph-placeholder",
174
+ "data-placeholder-culled": "true",
175
+ "data-placeholder-size": String(node.nodeSize),
176
+ "data-placeholder-block-id": node.attrs.blockId ?? "",
177
+ style: "min-height: 20px; contain: strict;",
178
+ "aria-hidden": "true",
179
+ },
180
+ 0,
181
+ ];
182
+ }
166
183
  const classes: string[] = ["leading-relaxed"];
167
184
  const styleId = node.attrs.styleId as string | null;
168
185
  const outlineLevel = node.attrs.outlineLevel as number | null;
@@ -893,6 +910,7 @@ export const editorSchema = new Schema({
893
910
  label: { default: "Locked" },
894
911
  detail: { default: "" },
895
912
  presentation: { default: "callout" },
913
+ placeholderSize: { default: null },
896
914
  },
897
915
  toDOM(node) {
898
916
  const fragmentId = node.attrs.fragmentId as string;
@@ -700,6 +700,29 @@ function buildOpaqueBlock(
700
700
  block: Extract<SurfaceBlockSnapshot, { kind: "opaque_block" }>,
701
701
  showUnsupportedObjectPreviews: boolean,
702
702
  ): PMNode {
703
+ // Viewport-culled placeholder: emit a single paragraph with ZWSP text so
704
+ // PM position math matches the original block span.
705
+ // See docs/plans/lane-2-render-performance.md Task 2.2.3.
706
+ const placeholderSize = block.placeholderSize ?? null;
707
+ if (placeholderSize !== null) {
708
+ const targetSize = placeholderSize as number;
709
+ if (targetSize <= 2) {
710
+ // Edge case: bare empty paragraph claims exactly 2 positions.
711
+ return editorSchema.nodes.paragraph.create(
712
+ { blockId: block.blockId, placeholderCulled: true },
713
+ Fragment.empty,
714
+ );
715
+ }
716
+ // General case: one paragraph with (targetSize - 2) ZWSP chars so the
717
+ // total PM positions = 1 (open) + (targetSize - 2) (text) + 1 (close) = targetSize.
718
+ const filler = "\u200b".repeat(targetSize - 2);
719
+ return editorSchema.nodes.paragraph.create(
720
+ { blockId: block.blockId, placeholderCulled: true },
721
+ editorSchema.text(filler),
722
+ );
723
+ }
724
+
725
+ // Real (non-placeholder) opaque_block: existing behaviour unchanged.
703
726
  return editorSchema.nodes.opaque_block.create({
704
727
  fragmentId: block.fragmentId,
705
728
  warningId: block.warningId,
@@ -23,6 +23,7 @@ export function createSurfaceDocumentBuildKey(input: {
23
23
  mediaPreviewKey: string;
24
24
  showUnsupportedObjectPreviews?: boolean;
25
25
  }): string {
26
+ const vp = input.surface?.viewportBlockRange ?? null;
26
27
  return JSON.stringify({
27
28
  surfaceIdentity:
28
29
  input.surface === undefined || input.surface === null
@@ -31,6 +32,7 @@ export function createSurfaceDocumentBuildKey(input: {
31
32
  activeStory: input.activeStory,
32
33
  mediaPreviewKey: input.mediaPreviewKey,
33
34
  showUnsupportedObjectPreviews: input.showUnsupportedObjectPreviews ?? false,
35
+ viewport: vp ? `${vp.start}:${vp.end}` : "full",
34
36
  });
35
37
  }
36
38
 
@@ -98,6 +98,7 @@ function buildPageBreakDecorationsFromProps(
98
98
  footerBandPx?: number;
99
99
  interGapPx?: number;
100
100
  } = {},
101
+ surfaceBlocks?: readonly import("../../api/public-types.ts").SurfaceBlockSnapshot[],
101
102
  ): ReturnType<typeof buildPageBreakDecorations> {
102
103
  if (!facet || !isMainStory) return [];
103
104
  if (typeof facet.getRenderFrame !== "function") return [];
@@ -133,6 +134,41 @@ function buildPageBreakDecorationsFromProps(
133
134
  })
134
135
  : undefined;
135
136
 
137
+ // L7 Phase 2 Task 2.2.4a — compute per-page block-index ranges from the
138
+ // render frame's page offsets + the surface blocks list. Each block has a
139
+ // `from`/`to` offset; we find the first and last block whose offset range
140
+ // falls within each page's [startOffset, nextPage.startOffset) window.
141
+ // This map is passed into `buildPageBreakDecorations` so the chrome widgets
142
+ // carry `data-page-first-block-index` / `data-page-last-block-index`
143
+ // attributes needed by `useVisibleBlockRange`.
144
+ let blockIndexRangeByPageIndex: Map<number, { first: number; last: number }> | undefined;
145
+ if (surfaceBlocks && surfaceBlocks.length > 0 && frame.pages.length > 0) {
146
+ blockIndexRangeByPageIndex = new Map();
147
+ for (let pi = 0; pi < frame.pages.length; pi++) {
148
+ const page = frame.pages[pi]!;
149
+ if (page.page.isBlankFiller) continue;
150
+ const pageStart = page.page.startOffset;
151
+ const pageEnd =
152
+ pi + 1 < frame.pages.length
153
+ ? frame.pages[pi + 1]!.page.startOffset
154
+ : Infinity;
155
+ let first = -1;
156
+ let last = -1;
157
+ for (let bi = 0; bi < surfaceBlocks.length; bi++) {
158
+ const block = surfaceBlocks[bi]!;
159
+ const blockFrom = block.from; // from is required on all SurfaceBlockSnapshot variants
160
+ // Block belongs to this page if its start falls within the page's offset window.
161
+ if (blockFrom >= pageStart && blockFrom < pageEnd) {
162
+ if (first === -1) first = bi;
163
+ last = bi;
164
+ }
165
+ }
166
+ if (first !== -1) {
167
+ blockIndexRangeByPageIndex.set(page.page.pageIndex, { first, last });
168
+ }
169
+ }
170
+ }
171
+
136
172
  return buildPageBreakDecorations({
137
173
  graph: fakeGraph as never,
138
174
  posture,
@@ -142,6 +178,7 @@ function buildPageBreakDecorationsFromProps(
142
178
  runtimeToPmOffset: (offset) => positionMap.runtimeToPm(offset),
143
179
  headerPreviewByPageId: previews?.headerPreviewByPageId,
144
180
  footerPreviewByPageId: previews?.footerPreviewByPageId,
181
+ blockIndexRangeByPageIndex,
145
182
  });
146
183
  }
147
184
 
@@ -512,6 +549,7 @@ export const TwProseMirrorSurface = forwardRef<
512
549
  footerBandPx: props.pageChromeFooterBandPx,
513
550
  interGapPx: props.pageChromeInterGapPx,
514
551
  },
552
+ snapshot.surface?.blocks,
515
553
  );
516
554
  const decorations = pageBreakDecos.length > 0
517
555
  ? DecorationSet.create(view.state.doc, [
@@ -0,0 +1,157 @@
1
+ import * as React from "react";
2
+
3
+ /**
4
+ * Fallback estimate of blocks per page. Used ONLY when no page markers
5
+ * are available (pre-observer window or a degenerate empty-pageMarkers
6
+ * input). Once the observer fires, actual per-page spans are read from
7
+ * `data-page-first-block-index` / `data-page-last-block-index` markers.
8
+ * Short (title-only) or very long (large-table) pages deviate from this,
9
+ * but the fallback is transient and only affects the initial render.
10
+ */
11
+ const ESTIMATED_BLOCKS_PER_PAGE_FALLBACK = 50;
12
+
13
+ /**
14
+ * Block-range hook — returns the range of surface block indices that should
15
+ * be rendered in PM as "real" (non-placeholder) blocks.
16
+ *
17
+ * Sources of truth:
18
+ * 1. IntersectionObserver on `[data-page-frame]` markers in the PM DOM.
19
+ * 2. Selection head block-index — always included (selection-guard).
20
+ * 3. Overscan — ±N pages around the visible set to avoid jank when scrolling.
21
+ *
22
+ * If the selection is far off-screen, the returned range spans both the
23
+ * visible window AND the selection's page (with the gap between filled in).
24
+ * Gap-filling is a deliberate correctness choice: position preservation does
25
+ * NOT require continuous viewport coverage, but continuous coverage simplifies
26
+ * the snapshot-projection step downstream.
27
+ */
28
+ export interface VisibleBlockRangeInput {
29
+ pageMarkers: readonly HTMLElement[];
30
+ overscanPages: number;
31
+ selectionBlockIndex: number | null;
32
+ totalBlockCount: number;
33
+ }
34
+
35
+ export interface BlockRange {
36
+ start: number; // inclusive
37
+ end: number; // exclusive
38
+ }
39
+
40
+ function readBlockIndex(el: HTMLElement, attr: string): number | null {
41
+ const v = el.getAttribute(attr);
42
+ if (v === null) return null;
43
+ const n = Number(v);
44
+ return Number.isFinite(n) ? n : null;
45
+ }
46
+
47
+ export function useVisibleBlockRange(input: VisibleBlockRangeInput): BlockRange {
48
+ const { pageMarkers, overscanPages, selectionBlockIndex, totalBlockCount } = input;
49
+ const [visiblePages, setVisiblePages] = React.useState<Set<number>>(() => new Set());
50
+
51
+ React.useEffect(() => {
52
+ // Reset: marker set changed (e.g. document reload). Stale page indices
53
+ // from the previous observer would look up against new markers and miss,
54
+ // falling through to the `Infinity` fallback and producing a transient
55
+ // over-wide range. Clear them now so the new observer's first callback
56
+ // is the single source of truth.
57
+ setVisiblePages(new Set());
58
+
59
+ if (pageMarkers.length === 0) return;
60
+ const view = pageMarkers[0].ownerDocument?.defaultView;
61
+ if (!view?.IntersectionObserver) return;
62
+
63
+ const observer = new view.IntersectionObserver(
64
+ (entries) => {
65
+ setVisiblePages((prev) => {
66
+ const next = new Set(prev);
67
+ let changed = false;
68
+ for (const entry of entries) {
69
+ const idx = readBlockIndex(entry.target as HTMLElement, "data-page-frame");
70
+ if (idx === null) continue;
71
+ const was = next.has(idx);
72
+ if (entry.isIntersecting && !was) {
73
+ next.add(idx);
74
+ changed = true;
75
+ } else if (!entry.isIntersecting && was) {
76
+ next.delete(idx);
77
+ changed = true;
78
+ }
79
+ }
80
+ return changed ? next : prev;
81
+ });
82
+ },
83
+ { root: null, rootMargin: "0px", threshold: 0 },
84
+ );
85
+
86
+ for (const marker of pageMarkers) observer.observe(marker);
87
+ return () => observer.disconnect();
88
+ }, [pageMarkers]);
89
+
90
+ return React.useMemo(() => {
91
+ if (totalBlockCount <= 0) return { start: 0, end: 0 };
92
+ if (visiblePages.size === 0 && selectionBlockIndex === null) {
93
+ // No visibility signal yet — return initial-load window (first 2 × overscanPages worth).
94
+ const initialEnd = Math.min(totalBlockCount, (overscanPages * 2 + 1) * ESTIMATED_BLOCKS_PER_PAGE_FALLBACK);
95
+ return { start: 0, end: initialEnd };
96
+ }
97
+
98
+ // Expand visiblePages by ±overscanPages.
99
+ const expanded = new Set<number>();
100
+ for (const p of visiblePages) {
101
+ for (let d = -overscanPages; d <= overscanPages; d++) expanded.add(p + d);
102
+ }
103
+
104
+ // Translate page indices → block indices using marker attrs.
105
+ let minBlock = Infinity;
106
+ let maxBlock = -Infinity;
107
+ for (const marker of pageMarkers) {
108
+ const idx = readBlockIndex(marker, "data-page-frame");
109
+ if (idx === null || !expanded.has(idx)) continue;
110
+ const first = readBlockIndex(marker, "data-page-first-block-index");
111
+ const last = readBlockIndex(marker, "data-page-last-block-index");
112
+ if (first !== null) minBlock = Math.min(minBlock, first);
113
+ if (last !== null) maxBlock = Math.max(maxBlock, last + 1);
114
+ }
115
+ if (minBlock === Infinity) {
116
+ minBlock = 0;
117
+ maxBlock = Math.min(totalBlockCount, (overscanPages * 2 + 1) * ESTIMATED_BLOCKS_PER_PAGE_FALLBACK);
118
+ }
119
+
120
+ // Selection-guard: if selection is outside [minBlock, maxBlock), extend to cover
121
+ // the entire page that contains the selection.
122
+ if (selectionBlockIndex !== null) {
123
+ if (selectionBlockIndex < minBlock) {
124
+ // Find the page that contains selectionBlockIndex and extend to its start.
125
+ for (const marker of pageMarkers) {
126
+ const first = readBlockIndex(marker, "data-page-first-block-index");
127
+ const last = readBlockIndex(marker, "data-page-last-block-index");
128
+ if (first !== null && last !== null && selectionBlockIndex >= first && selectionBlockIndex <= last) {
129
+ if (first < minBlock) minBlock = first;
130
+ break;
131
+ }
132
+ }
133
+ // Fallback: just include the block itself.
134
+ if (selectionBlockIndex < minBlock) minBlock = selectionBlockIndex;
135
+ }
136
+ if (selectionBlockIndex >= maxBlock) {
137
+ // Find the page that contains selectionBlockIndex and extend to its end.
138
+ for (const marker of pageMarkers) {
139
+ const first = readBlockIndex(marker, "data-page-first-block-index");
140
+ const last = readBlockIndex(marker, "data-page-last-block-index");
141
+ if (first !== null && last !== null && selectionBlockIndex >= first && selectionBlockIndex <= last) {
142
+ if (last + 1 > maxBlock) maxBlock = last + 1;
143
+ break;
144
+ }
145
+ }
146
+ // Fallback: just include the block itself.
147
+ if (selectionBlockIndex >= maxBlock) maxBlock = selectionBlockIndex + 1;
148
+ }
149
+ }
150
+
151
+ // Clamp to doc bounds.
152
+ return {
153
+ start: Math.max(0, minBlock),
154
+ end: Math.min(totalBlockCount, maxBlock),
155
+ };
156
+ }, [visiblePages, selectionBlockIndex, pageMarkers, overscanPages, totalBlockCount]);
157
+ }