@beyondwork/docx-react-component 1.0.73 → 1.0.75

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 (59) hide show
  1. package/package.json +1 -1
  2. package/src/api/anchor-conversion.ts +2 -2
  3. package/src/api/public-types.ts +40 -6
  4. package/src/api/v3/_runtime-handle.ts +15 -0
  5. package/src/api/v3/runtime/workflow.ts +130 -1
  6. package/src/api/v3/ui/_types.ts +21 -0
  7. package/src/api/v3/ui/overlays.ts +276 -2
  8. package/src/api/v3/ui/scope.ts +113 -1
  9. package/src/compare/diff-engine.ts +1 -2
  10. package/src/core/commands/index.ts +14 -15
  11. package/src/core/selection/anchor-conversion.ts +2 -2
  12. package/src/core/selection/mapping.ts +10 -8
  13. package/src/core/selection/review-anchors.ts +3 -3
  14. package/src/io/export/export-session.ts +53 -0
  15. package/src/io/export/serialize-comments.ts +4 -4
  16. package/src/io/export/serialize-runtime-revisions.ts +10 -10
  17. package/src/io/export/split-review-boundaries.ts +4 -4
  18. package/src/io/export/split-story-blocks-for-runtime-revisions.ts +2 -2
  19. package/src/io/ooxml/parse-comments.ts +2 -2
  20. package/src/io/ooxml/parse-headers-footers.ts +7 -13
  21. package/src/io/ooxml/parse-main-document.ts +7 -31
  22. package/src/io/ooxml/table-opaque-preservation.ts +171 -0
  23. package/src/model/anchor.ts +9 -1
  24. package/src/model/canonical-document.ts +76 -3
  25. package/src/preservation/store.ts +24 -0
  26. package/src/review/store/comment-anchors.ts +1 -1
  27. package/src/review/store/comment-remapping.ts +1 -1
  28. package/src/review/store/revision-actions.ts +4 -4
  29. package/src/review/store/revision-types.ts +1 -1
  30. package/src/review/store/scope-tag-diff.ts +1 -1
  31. package/src/runtime/collab/map-local-selection-on-remote-replay.ts +7 -7
  32. package/src/runtime/document-runtime.ts +233 -38
  33. package/src/runtime/formatting/formatting-context.ts +1 -1
  34. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  35. package/src/runtime/layout/layout-engine-version.ts +9 -1
  36. package/src/runtime/layout/public-facet.ts +27 -0
  37. package/src/runtime/scopes/evidence.ts +1 -1
  38. package/src/runtime/scopes/review-bundle.ts +1 -1
  39. package/src/runtime/scopes/scope-range.ts +1 -1
  40. package/src/runtime/selection/post-edit-validator.ts +4 -4
  41. package/src/runtime/surface-projection.ts +48 -4
  42. package/src/runtime/workflow/scope-writer.ts +212 -10
  43. package/src/session/import/review-import.ts +12 -12
  44. package/src/session/import/workflow-scope-import.ts +9 -8
  45. package/src/shell/session-bootstrap.ts +4 -0
  46. package/src/ui-tailwind/editor-surface/pm-schema.ts +22 -2
  47. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +5 -2
  48. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +22 -3
  49. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +7 -3
  50. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +5 -1
  51. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +5 -1
  52. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +7 -5
  53. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +99 -43
  54. package/src/ui-tailwind/review-workspace/use-page-markers.ts +48 -7
  55. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +0 -13
  56. package/src/ui-tailwind/tw-review-workspace.tsx +13 -41
  57. package/src/validation/compatibility-engine.ts +1 -1
  58. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +0 -114
  59. package/src/ui-tailwind/review-workspace/tw-review-workspace-page-toolbar.tsx +0 -240
@@ -171,7 +171,12 @@ import {
171
171
  seedMarkerBackedScopeIds,
172
172
  type OverlayStore,
173
173
  } from "./workflow/overlay-store.ts";
174
- import type { RuntimeOperationPlan, ScopeBundle } from "./scopes/semantic-scope-types.ts";
174
+ import type {
175
+ RuntimeOperationPlan,
176
+ ScopeBundle,
177
+ SemanticScope,
178
+ SemanticScopeKind,
179
+ } from "./scopes/semantic-scope-types.ts";
175
180
  import { createScopeCompilerService } from "./scopes/compiler-service.ts";
176
181
  import {
177
182
  createWorkflowCoordinator,
@@ -527,6 +532,42 @@ export interface DocumentRuntime {
527
532
  * handle (e.g. `getScope`).
528
533
  */
529
534
  compileScopeBundleById(scopeId: string, nowUtc: string): ScopeBundle | null;
535
+ /**
536
+ * KI-008 (2026-04-24) — batch enumeration primitive for L10
537
+ * `ui.scope.list(filter?)`. Returns the compiled `SemanticScope[]`
538
+ * through the existing `createScopeCompilerService` facade, applies
539
+ * the optional kind filter + limit over the result, and hands back a
540
+ * fresh array (callers may sort / splice freely without leaking
541
+ * into internal compiler state). L10 `src/api/v3/ui/**` can't import
542
+ * `src/runtime/scopes/**` per its purity guard; this handle method
543
+ * is the narrow seam through which `ui.scope.list` reaches
544
+ * enumeration. `ai.listScopes` delegates through the same primitive
545
+ * so both surfaces project the same identity set.
546
+ */
547
+ compileScopeList(filter?: {
548
+ readonly kind?: SemanticScopeKind;
549
+ readonly limit?: number;
550
+ }): readonly SemanticScope[];
551
+ /**
552
+ * KI-008 close (2026-04-24) — scopeId-keyed scope-card projection
553
+ * for L10 `ui.scope.card(scopeId)`. Delegates to
554
+ * `runtime.workflow.getAllScopeCardModels()` + filters to the
555
+ * matching scope. Returns `null` when the id doesn't project a
556
+ * card (not on overlay / no matching segment).
557
+ */
558
+ compileScopeCardById(
559
+ scopeId: string,
560
+ ): import("../api/public-types.ts").ScopeCardModel | null;
561
+ /**
562
+ * KI-008 close (2026-04-24) — scope-rail snapshot for L10
563
+ * `ui.scope.rail(options?)`. Wraps `getRailSegments(pageIndex)`
564
+ * (narrowed) or `getAllRailSegments()` (span) into a
565
+ * `ScopeRailSnapshot` envelope so the UI surface has a stable
566
+ * shape regardless of page filtering.
567
+ */
568
+ compileScopeRailSnapshot(options?: {
569
+ readonly pageIndex?: number;
570
+ }): import("../api/public-types.ts").ScopeRailSnapshot;
530
571
  /**
531
572
  * Debug projector support — readonly view of scope ids the runtime
532
573
  * considers marker-backed (present in both `collectScopeLocations(doc)`
@@ -837,6 +878,20 @@ export interface DocumentRuntime {
837
878
  * Safe to call at any frequency; identical ranges are a no-op.
838
879
  */
839
880
  setVisibleBlockRange(range: { start: number; end: number }): void;
881
+ /**
882
+ * Multi-interval variant of `setVisibleBlockRange`. The surface projection
883
+ * treats a block as real if its index falls in ANY of the supplied
884
+ * intervals. Use this when the editor's visible window and the caret's
885
+ * page are disjoint on a long document — emit the viewport + the caret's
886
+ * page as two ranges instead of one merged range so the gap between them
887
+ * stays virtualized (placeholder-culled) instead of being realized.
888
+ *
889
+ * The array is stored by reference; callers are responsible for passing
890
+ * non-overlapping intervals sorted ascending by `start`. An empty array
891
+ * is equivalent to passing a single full-document range (legacy
892
+ * "all-real" behavior). Identical ranges are a no-op.
893
+ */
894
+ setVisibleBlockRanges(ranges: readonly { start: number; end: number }[]): void;
840
895
  /**
841
896
  * Triggers a surface-only refresh that applies the latest visible block range
842
897
  * without running the full commit pipeline. Used by scroll handlers.
@@ -959,6 +1014,49 @@ interface HistoryState {
959
1014
  * stale `items[].anchor` never reaches a consumer. The direct
960
1015
  * `runtime.getReviewWorkSnapshot()` API does not use this cache.
961
1016
  */
1017
+ /**
1018
+ * Normalize incoming viewport intervals: drop degenerate ranges (end <= start),
1019
+ * sort by `start`, merge overlapping / touching intervals. Returns a frozen
1020
+ * array so callers can't mutate the cached state.
1021
+ *
1022
+ * The sort + merge is important: `refreshSurfaceOnly`'s idempotency key is
1023
+ * a string serialization, so `[{0,5},{100,105}]` and `[{100,105},{0,5}]`
1024
+ * must compare equal; likewise `[{0,5},{4,10}]` and `[{0,10}]` represent
1025
+ * identical realization sets and should key identically.
1026
+ */
1027
+ function normalizeViewportRanges(
1028
+ ranges: readonly { start: number; end: number }[],
1029
+ ): readonly { start: number; end: number }[] {
1030
+ const cleaned = ranges
1031
+ .filter((r) => Number.isFinite(r.start) && Number.isFinite(r.end) && r.end > r.start)
1032
+ .map((r) => ({ start: r.start, end: r.end }));
1033
+ if (cleaned.length === 0) return Object.freeze([]);
1034
+ cleaned.sort((a, b) => a.start - b.start);
1035
+ const merged: { start: number; end: number }[] = [cleaned[0]!];
1036
+ for (let i = 1; i < cleaned.length; i += 1) {
1037
+ const next = cleaned[i]!;
1038
+ const last = merged[merged.length - 1]!;
1039
+ // Mutation is safe: `last` is a fresh object from the `.map` step above,
1040
+ // not aliased with any caller-provided input. The `Object.freeze` at the
1041
+ // return site only runs after the merge loop finishes.
1042
+ if (next.start <= last.end) {
1043
+ if (next.end > last.end) last.end = next.end;
1044
+ } else {
1045
+ merged.push(next);
1046
+ }
1047
+ }
1048
+ return Object.freeze(merged.map((r) => Object.freeze(r)));
1049
+ }
1050
+
1051
+ /** Stable serialization of normalized viewport ranges for fingerprinting. */
1052
+ function serializeViewportRanges(
1053
+ ranges: readonly { start: number; end: number }[] | null,
1054
+ ): string {
1055
+ if (ranges === null) return "null";
1056
+ if (ranges.length === 0) return "empty";
1057
+ return ranges.map((r) => `${r.start}:${r.end}`).join("|");
1058
+ }
1059
+
962
1060
  function computeWorkflowMarkupStructuralHash(
963
1061
  wfMarkup: WorkflowMarkupSnapshot,
964
1062
  ): string {
@@ -1215,19 +1313,14 @@ export function createDocumentRuntime(
1215
1313
  // so the facet forwards to the runtime method. Forward-reference pattern matches
1216
1314
  // `renderKernelRef` — see that example. See spec-review notes on commit 0b03bfa7.
1217
1315
  setVisibleBlockRange: (range) => {
1218
- if (
1219
- viewportBlockRange &&
1220
- viewportBlockRange.start === range.start &&
1221
- viewportBlockRange.end === range.end
1222
- ) {
1223
- return;
1224
- }
1225
- viewportBlockRange = { start: range.start, end: range.end };
1226
- perfCounters.increment("runtime.viewport.updates");
1316
+ applyViewportRanges([range]);
1317
+ },
1318
+ setVisibleBlockRanges: (ranges) => {
1319
+ applyViewportRanges(ranges);
1227
1320
  },
1228
1321
  requestViewportRefresh: () => {
1229
1322
  perfCounters.increment("runtime.viewport.refreshes");
1230
- refreshSurfaceOnly();
1323
+ maybeRefreshSurfaceForViewport();
1231
1324
  },
1232
1325
  });
1233
1326
  // D1 — wire the layout-guard warning emitter so `return []` guard sites
@@ -1255,13 +1348,44 @@ export function createDocumentRuntime(
1255
1348
  renderKernel: () => renderKernelRef,
1256
1349
  getCanonicalDocument: () => state.document,
1257
1350
  });
1258
- // L7 Phase 2 — viewport block range for surface culling.
1259
- let viewportBlockRange: { start: number; end: number } | null = null;
1351
+ // L7 Phase 2 — viewport block range(s) for surface culling. Multi-interval
1352
+ // canonical state: a block is real if its index falls in ANY interval.
1353
+ // Stored as a frozen, sorted, non-overlapping list so `applyViewportRanges`
1354
+ // can identity-compare for fast no-ops; `null` = "all blocks real" (legacy).
1355
+ let viewportBlockRanges: readonly { start: number; end: number }[] | null = null;
1356
+ /** Serialized fingerprint of the active ranges — used as the idempotency key for `refreshSurfaceOnly`. */
1357
+ let viewportRangesKey: string = serializeViewportRanges(null);
1358
+
1359
+ function applyViewportRanges(
1360
+ incoming: readonly { start: number; end: number }[] | null,
1361
+ ): void {
1362
+ // Normalize: empty array / null both mean "no culling".
1363
+ const normalized =
1364
+ incoming == null || incoming.length === 0
1365
+ ? null
1366
+ : normalizeViewportRanges(incoming);
1367
+ const nextKey = serializeViewportRanges(normalized);
1368
+ if (nextKey === viewportRangesKey) return;
1369
+ viewportBlockRanges = normalized;
1370
+ viewportRangesKey = nextKey;
1371
+ perfCounters.increment("runtime.viewport.updates");
1372
+ }
1260
1373
 
1261
1374
  let cachedSurface:
1262
1375
  | {
1263
1376
  revisionToken: string;
1264
1377
  activeStoryKey: string;
1378
+ /**
1379
+ * Serialized viewport ranges at build time. A surface snapshot is
1380
+ * only reusable for a subsequent `getCachedSurface` call if the
1381
+ * runtime's current `viewportRangesKey` matches — otherwise the
1382
+ * cached snapshot realizes a different set of blocks as real vs
1383
+ * placeholder-culled than the current runtime state demands.
1384
+ * Pre-refactor/11b this was a (revisionToken, activeStoryKey) key
1385
+ * only, which was silently stale-prone when callers applied new
1386
+ * viewport ranges without also calling `requestViewportRefresh()`.
1387
+ */
1388
+ viewportRangesKey: string;
1265
1389
  snapshot: RuntimeRenderSnapshot["surface"];
1266
1390
  }
1267
1391
  | undefined;
@@ -1423,13 +1547,14 @@ export function createDocumentRuntime(
1423
1547
  if (
1424
1548
  cachedSurface &&
1425
1549
  cachedSurface.revisionToken === state.revisionToken &&
1426
- cachedSurface.activeStoryKey === activeStoryKey
1550
+ cachedSurface.activeStoryKey === activeStoryKey &&
1551
+ cachedSurface.viewportRangesKey === viewportRangesKey
1427
1552
  ) {
1428
1553
  return cachedSurface.snapshot;
1429
1554
  }
1430
1555
 
1431
1556
  const snapshot = createEditorSurfaceSnapshot(document, state.selection, nextActiveStory, {
1432
- viewportBlockRange,
1557
+ viewportBlockRanges,
1433
1558
  ...(effectiveMarkupModeProvider
1434
1559
  ? { getEffectiveMarkupMode: effectiveMarkupModeProvider }
1435
1560
  : {}),
@@ -1439,8 +1564,13 @@ export function createDocumentRuntime(
1439
1564
  cachedSurface = {
1440
1565
  revisionToken: state.revisionToken,
1441
1566
  activeStoryKey,
1567
+ viewportRangesKey,
1442
1568
  snapshot,
1443
1569
  };
1570
+ // Keep the scroll-path fingerprint in lockstep so a subsequent
1571
+ // `maybeRefreshSurfaceForViewport` sees the freshly-built snapshot
1572
+ // and short-circuits instead of paying a redundant projection.
1573
+ cachedSurfaceFingerprint = `${state.revisionToken}|${activeStoryKey}|${viewportRangesKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
1444
1574
  return snapshot;
1445
1575
  }
1446
1576
 
@@ -2241,10 +2371,28 @@ export function createDocumentRuntime(
2241
2371
  }
2242
2372
 
2243
2373
  /**
2244
- * L7 Phase 2 surface-only refresh triggered by viewport scroll.
2245
- * Rebuilds only the surface facet (with the current viewportBlockRange) and
2246
- * splices it into cachedRenderSnapshot. Does NOT rebuild layout, comments,
2247
- * trackedChanges, or compatibility. Does NOT increment "refresh.all".
2374
+ * Fingerprint over the inputs that the viewport-refresh fast-path knows
2375
+ * about. Used to short-circuit {@link maybeRefreshSurfaceForViewport}
2376
+ * when nothing that affects the scroll-driven projection has changed
2377
+ * since the last build. Scroll handlers fire effectively-identical
2378
+ * requests on every tick (IO coalescing, overscan nudges that don't
2379
+ * flip any page's visible state); without this gate every tick would
2380
+ * rebuild the surface + notify listeners + force PM `view.updateState()`.
2381
+ *
2382
+ * NOT suitable for callers that invalidate external projection inputs
2383
+ * (markup-mode provider, etc.) because those inputs are read at
2384
+ * projection time and are not captured in the fingerprint. Those callers
2385
+ * use {@link refreshSurfaceOnly} directly (unconditional rebuild).
2386
+ */
2387
+ let cachedSurfaceFingerprint: string | null = null;
2388
+
2389
+ /**
2390
+ * L7 Phase 2 — surface-only refresh. Rebuilds the surface facet (with
2391
+ * the current viewportBlockRanges) and splices it into
2392
+ * cachedRenderSnapshot. Does NOT rebuild layout, comments, trackedChanges,
2393
+ * or compatibility. Does NOT increment "refresh.all". Callers that
2394
+ * know an input outside the fingerprint changed (markup-mode provider,
2395
+ * etc.) use this directly.
2248
2396
  */
2249
2397
  function refreshSurfaceOnly(): void {
2250
2398
  const newSurface = createEditorSurfaceSnapshot(
@@ -2252,12 +2400,13 @@ export function createDocumentRuntime(
2252
2400
  state.selection,
2253
2401
  activeStory,
2254
2402
  {
2255
- viewportBlockRange,
2403
+ viewportBlockRanges,
2256
2404
  ...(effectiveMarkupModeProvider
2257
2405
  ? { getEffectiveMarkupMode: effectiveMarkupModeProvider }
2258
2406
  : {}),
2259
2407
  },
2260
2408
  );
2409
+ cachedSurfaceFingerprint = `${state.revisionToken}|${storyTargetKey(activeStory)}|${viewportRangesKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
2261
2410
  // Refresh the cache with the just-built snapshot so a subsequent
2262
2411
  // refreshRenderSnapshot (same revisionToken + activeStoryKey) hits cache.
2263
2412
  // Without this repopulate, the next non-mutation refresh wastes a second
@@ -2265,6 +2414,7 @@ export function createDocumentRuntime(
2265
2414
  cachedSurface = {
2266
2415
  revisionToken: state.revisionToken,
2267
2416
  activeStoryKey: storyTargetKey(activeStory),
2417
+ viewportRangesKey,
2268
2418
  snapshot: newSurface,
2269
2419
  };
2270
2420
  cachedRenderSnapshot = {
@@ -2277,8 +2427,33 @@ export function createDocumentRuntime(
2277
2427
  }
2278
2428
  }
2279
2429
 
2430
+ /**
2431
+ * Viewport-refresh fast path — short-circuits when the tracked
2432
+ * fingerprint matches, otherwise delegates to {@link refreshSurfaceOnly}.
2433
+ * Used by `requestViewportRefresh` so steady-state scroll doesn't pay
2434
+ * a full projection + listener fan-out on every tick.
2435
+ *
2436
+ * Safe because every external input that can change the projection
2437
+ * output without ticking revisionToken / selection / viewportRanges /
2438
+ * activeStory is wired to call `refreshSurfaceOnly` directly (see
2439
+ * `setEffectiveMarkupModeProvider`, `invalidateForMarkupModeChange`).
2440
+ */
2441
+ function maybeRefreshSurfaceForViewport(): void {
2442
+ const fingerprint = `${state.revisionToken}|${storyTargetKey(activeStory)}|${viewportRangesKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
2443
+ if (cachedSurfaceFingerprint === fingerprint && cachedSurface) {
2444
+ return;
2445
+ }
2446
+ refreshSurfaceOnly();
2447
+ }
2448
+
2280
2449
  function invalidateDerivedRuntimeCaches(): void {
2281
2450
  cachedSurface = undefined;
2451
+ // Keep the scroll-path fingerprint in lockstep with `cachedSurface`.
2452
+ // Otherwise a post-commit `requestViewportRefresh` would compute a new
2453
+ // fingerprint, see it differs from the stale pre-commit value, and
2454
+ // rebuild + broadcast even when the freshly-hydrated `cachedSurface`
2455
+ // already matches — wasted projection + listener fan-out.
2456
+ cachedSurfaceFingerprint = null;
2282
2457
  cachedCompatibility = undefined;
2283
2458
  cachedComments = undefined;
2284
2459
  cachedTrackedChanges = undefined;
@@ -3289,6 +3464,31 @@ export function createDocumentRuntime(
3289
3464
  compileScopeBundleById(scopeId, nowUtc) {
3290
3465
  return createScopeCompilerService(runtime).compileBundleById(scopeId, nowUtc);
3291
3466
  },
3467
+ compileScopeList(filter) {
3468
+ const all = createScopeCompilerService(runtime).compileAllScopes();
3469
+ const byKind = filter?.kind
3470
+ ? all.filter((s) => s.kind === filter.kind)
3471
+ : all.slice();
3472
+ if (typeof filter?.limit === "number") {
3473
+ return byKind.slice(0, filter.limit);
3474
+ }
3475
+ return byKind;
3476
+ },
3477
+ compileScopeCardById(scopeId) {
3478
+ const cards = workflowCoordinator.getAllScopeCardModels();
3479
+ return cards.find((card) => card.scopeId === scopeId) ?? null;
3480
+ },
3481
+ compileScopeRailSnapshot(options) {
3482
+ const pageIndex = options?.pageIndex;
3483
+ const segments =
3484
+ typeof pageIndex === "number"
3485
+ ? workflowCoordinator.getRailSegments(pageIndex)
3486
+ : workflowCoordinator.getAllRailSegments();
3487
+ return {
3488
+ segments: Object.freeze([...segments]),
3489
+ ...(typeof pageIndex === "number" ? { pageIndex } : {}),
3490
+ };
3491
+ },
3292
3492
  getMarkerBackedScopeIds() {
3293
3493
  return workflowCoordinator.getMarkerBackedScopeIds();
3294
3494
  },
@@ -4219,19 +4419,14 @@ export function createDocumentRuntime(
4219
4419
  perfCounters.reset();
4220
4420
  },
4221
4421
  setVisibleBlockRange(range) {
4222
- if (
4223
- viewportBlockRange &&
4224
- viewportBlockRange.start === range.start &&
4225
- viewportBlockRange.end === range.end
4226
- ) {
4227
- return;
4228
- }
4229
- viewportBlockRange = { start: range.start, end: range.end };
4230
- perfCounters.increment("runtime.viewport.updates");
4422
+ applyViewportRanges([range]);
4423
+ },
4424
+ setVisibleBlockRanges(ranges) {
4425
+ applyViewportRanges(ranges);
4231
4426
  },
4232
4427
  requestViewportRefresh() {
4233
4428
  perfCounters.increment("runtime.viewport.refreshes");
4234
- refreshSurfaceOnly();
4429
+ maybeRefreshSurfaceForViewport();
4235
4430
  },
4236
4431
  };
4237
4432
 
@@ -5668,8 +5863,8 @@ function semanticallyEqualCommentThreads(
5668
5863
  if (prevAnchor.kind !== nextAnchor.kind) return false;
5669
5864
  if (prevAnchor.kind === "range" && nextAnchor.kind === "range") {
5670
5865
  return (
5671
- prevAnchor.range.from === nextAnchor.range.from &&
5672
- prevAnchor.range.to === nextAnchor.range.to
5866
+ prevAnchor.from === nextAnchor.from &&
5867
+ prevAnchor.to === nextAnchor.to
5673
5868
  );
5674
5869
  }
5675
5870
  if (prevAnchor.kind === "node" && nextAnchor.kind === "node") {
@@ -6038,8 +6233,8 @@ function toAnchorBounds(anchor: InternalEditorAnchorProjection): { from: number;
6038
6233
  switch (anchor.kind) {
6039
6234
  case "range":
6040
6235
  return {
6041
- from: Math.min(anchor.range.from, anchor.range.to),
6042
- to: Math.max(anchor.range.from, anchor.range.to),
6236
+ from: Math.min(anchor.from, anchor.to),
6237
+ to: Math.max(anchor.from, anchor.to),
6043
6238
  };
6044
6239
  case "node":
6045
6240
  return {
@@ -7212,12 +7407,12 @@ function remapProtectionSnapshot(
7212
7407
  "preserve-only: permission range could not be remapped after runtime edits",
7213
7408
  };
7214
7409
  }
7215
- if (mapped.range.from !== range.start || mapped.range.to !== range.end) {
7410
+ if (mapped.from !== range.start || mapped.to !== range.end) {
7216
7411
  changed = true;
7217
7412
  return {
7218
7413
  ...range,
7219
- start: mapped.range.from,
7220
- end: mapped.range.to,
7414
+ start: mapped.from,
7415
+ end: mapped.to,
7221
7416
  };
7222
7417
  }
7223
7418
  return range;
@@ -7366,7 +7561,7 @@ function resolveClearHighlightRange(
7366
7561
  }
7367
7562
  const active = selection.activeRange;
7368
7563
  if (active.kind !== "range") return null;
7369
- return { from: active.range.from, to: active.range.to };
7564
+ return { from: active.from, to: active.to };
7370
7565
  }
7371
7566
 
7372
7567
  function expandRangeToHighlightExtent(
@@ -891,7 +891,7 @@ function buildRevisionRangeIndex(
891
891
  const anchor = revision.anchor;
892
892
  let range: { from: number; to: number } | undefined;
893
893
  if (anchor.kind === "range") {
894
- range = { from: anchor.range.from, to: anchor.range.to };
894
+ range = { from: anchor.from, to: anchor.to };
895
895
  } else if (anchor.kind === "node") {
896
896
  range = { from: anchor.at, to: anchor.at };
897
897
  } else {
@@ -68,6 +68,7 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
68
68
  getDirtyFieldFamilies: () => [],
69
69
  getFieldDirtinessReport: () => emptyReport,
70
70
  setVisibleBlockRange: () => undefined,
71
+ setVisibleBlockRanges: () => undefined,
71
72
  requestViewportRefresh: () => undefined,
72
73
  subscribe: (_listener: (event: LayoutFacetEvent) => void) => () => undefined,
73
74
  };
@@ -947,8 +947,16 @@
947
947
  * all emit/honor framePr end-to-end. Cache envelopes from v54
948
948
  * invalidate because any future document with a real body framePr
949
949
  * paginates differently.
950
+ *
951
+ * 56 — perf(11b,07) `8d07de1b` multi-range viewport realization.
952
+ * `WordReviewEditorLayoutFacet` gained `setVisibleBlockRanges` for
953
+ * multi-range viewport realization (paired with coord-07 §2.9
954
+ * `runtime.viewport.subscribe`). The contract widened but the
955
+ * underlying layout algorithm is unchanged — persisted envelopes
956
+ * remain shape-compatible. Bump is defensive so any consumer that
957
+ * keyed on the facet contract refreshes the cache.
950
958
  */
951
- export const LAYOUT_ENGINE_VERSION = 55 as const;
959
+ export const LAYOUT_ENGINE_VERSION = 56 as const;
952
960
 
953
961
  /**
954
962
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -580,6 +580,18 @@ export interface WordReviewEditorLayoutFacet {
580
580
  * identical ranges are a no-op inside the runtime.
581
581
  */
582
582
  setVisibleBlockRange(range: { start: number; end: number }): void;
583
+ /**
584
+ * Multi-interval variant of `setVisibleBlockRange`. Delegates to
585
+ * `DocumentRuntime.setVisibleBlockRanges`. Use when the editor's
586
+ * visible viewport and the caret's page are disjoint — emitting the
587
+ * two ranges separately keeps the gap between them virtualized
588
+ * instead of being realized as real blocks, which was the dominant
589
+ * scroll-cost regression on long documents (see the perf wiki
590
+ * "Viewport realization").
591
+ */
592
+ setVisibleBlockRanges(
593
+ ranges: readonly { start: number; end: number }[],
594
+ ): void;
583
595
  /**
584
596
  * Triggers a surface-only refresh applying the latest visible block range.
585
597
  * Delegates to `DocumentRuntime.requestViewportRefresh`.
@@ -625,6 +637,9 @@ export interface CreateLayoutFacetInput {
625
637
  * (e.g. tests, inert facet, standalone use) both methods are no-ops.
626
638
  */
627
639
  setVisibleBlockRange?: (range: { start: number; end: number }) => void;
640
+ setVisibleBlockRanges?: (
641
+ ranges: readonly { start: number; end: number }[],
642
+ ) => void;
628
643
  requestViewportRefresh?: () => void;
629
644
  }
630
645
 
@@ -1236,6 +1251,18 @@ export function createLayoutFacet(
1236
1251
  input.setVisibleBlockRange?.(range);
1237
1252
  },
1238
1253
 
1254
+ setVisibleBlockRanges(ranges) {
1255
+ // Prefer the multi-range delegate when the wiring supplies it;
1256
+ // otherwise fall back to the scalar delegate with the first range so
1257
+ // callers that only wire the legacy path still receive something
1258
+ // actionable (degrades to pre-multi-range behavior).
1259
+ if (input.setVisibleBlockRanges) {
1260
+ input.setVisibleBlockRanges(ranges);
1261
+ } else if (input.setVisibleBlockRange && ranges.length > 0) {
1262
+ input.setVisibleBlockRange(ranges[0]!);
1263
+ }
1264
+ },
1265
+
1239
1266
  requestViewportRefresh() {
1240
1267
  input.requestViewportRefresh?.();
1241
1268
  },
@@ -38,7 +38,7 @@ import type {
38
38
  function anchorRange(anchor: CanonicalAnchor): ScopePositionRange | null {
39
39
  switch (anchor.kind) {
40
40
  case "range":
41
- return { from: anchor.range.from, to: anchor.range.to };
41
+ return { from: anchor.from, to: anchor.to };
42
42
  case "node":
43
43
  return { from: anchor.at, to: anchor.at };
44
44
  case "detached":
@@ -57,7 +57,7 @@ function anchorToRange(
57
57
  ): ScopePositionRange | null {
58
58
  switch (anchor.kind) {
59
59
  case "range":
60
- return { from: anchor.range.from, to: anchor.range.to };
60
+ return { from: anchor.from, to: anchor.to };
61
61
  case "node":
62
62
  return { from: anchor.at, to: anchor.at };
63
63
  case "detached":
@@ -39,7 +39,7 @@ import type {
39
39
  function anchorToRange(anchor: CanonicalAnchor): ScopePositionRange | null {
40
40
  switch (anchor.kind) {
41
41
  case "range":
42
- return { from: anchor.range.from, to: anchor.range.to };
42
+ return { from: anchor.from, to: anchor.to };
43
43
  case "node":
44
44
  return { from: anchor.at, to: anchor.at };
45
45
  case "detached":
@@ -64,8 +64,7 @@ export function validateSelectionAgainstDocument(
64
64
  head: collapsed,
65
65
  isCollapsed: true,
66
66
  activeRange: {
67
- kind: "range",
68
- range: { from: collapsed, to: collapsed },
67
+ kind: "range", from: collapsed, to: collapsed,
69
68
  assoc: {
70
69
  start: selection.activeRange.assoc,
71
70
  end: selection.activeRange.assoc,
@@ -84,7 +83,8 @@ export function validateSelectionAgainstDocument(
84
83
  return selection;
85
84
  }
86
85
 
87
- const range = { from: Math.min(anchor, head), to: Math.max(anchor, head) };
86
+ const from = Math.min(anchor, head);
87
+ const to = Math.max(anchor, head);
88
88
  const assoc =
89
89
  selection.activeRange.kind === "range"
90
90
  ? selection.activeRange.assoc
@@ -94,7 +94,7 @@ export function validateSelectionAgainstDocument(
94
94
  anchor,
95
95
  head,
96
96
  isCollapsed: anchor === head,
97
- activeRange: { kind: "range", range, assoc },
97
+ activeRange: { kind: "range", from, to, assoc },
98
98
  };
99
99
  }
100
100
 
@@ -95,6 +95,29 @@ interface ParagraphAccumulator {
95
95
  }
96
96
 
97
97
  export interface SurfaceProjectionOptions {
98
+ /**
99
+ * Block-index intervals to render as real (non-placeholder). Blocks whose
100
+ * index falls in ANY interval are built as real `SurfaceBlockSnapshot`s;
101
+ * all others become size-preserving `placeholder-culled` opaque blocks.
102
+ *
103
+ * The canonical shape is an array of non-overlapping intervals (typically
104
+ * one for the visible viewport + overscan, and — when the caret is scrolled
105
+ * off-screen — a second one covering the caret's page so the selection
106
+ * block stays real without realizing the document-scale gap between the
107
+ * two). Callers still supplying the legacy scalar `viewportBlockRange` get
108
+ * wrapped into a single-element array internally.
109
+ *
110
+ * **Invariant (caller responsibility):** intervals must be sorted ascending
111
+ * by `start` and non-overlapping. The projection function itself only
112
+ * checks membership — it does not sort or merge — so an unsorted input
113
+ * produces a correct `isInViewport` answer but the snapshot's
114
+ * `viewportBlockRanges` field carries the unnormalized shape to downstream
115
+ * consumers (e.g. the surface-build-key serializer). Runtime callers
116
+ * route through `DocumentRuntime.applyViewportRanges` which normalizes;
117
+ * if you call `createEditorSurfaceSnapshot` directly, normalize first.
118
+ */
119
+ viewportBlockRanges?: readonly { start: number; end: number }[] | null;
120
+ /** @deprecated use `viewportBlockRanges`. Kept for back-compat; wrapped into a 1-element array when supplied alone. */
98
121
  viewportBlockRange?: { start: number; end: number } | null;
99
122
  /**
100
123
  * Active markup mode. When set together with the document's
@@ -146,7 +169,13 @@ export function createEditorSurfaceSnapshot(
146
169
  activeStory: EditorStoryTarget = { kind: "main" },
147
170
  options: SurfaceProjectionOptions = {},
148
171
  ): EditorSurfaceSnapshot {
149
- const viewportBlockRange = options.viewportBlockRange ?? null;
172
+ const viewportBlockRanges: readonly { start: number; end: number }[] | null = (() => {
173
+ if (options.viewportBlockRanges != null) {
174
+ return options.viewportBlockRanges.length === 0 ? null : options.viewportBlockRanges;
175
+ }
176
+ if (options.viewportBlockRange != null) return [options.viewportBlockRange];
177
+ return null;
178
+ })();
150
179
  const root = normalizeDocumentRoot({
151
180
  type: "doc",
152
181
  children: [...getStoryBlocks(document, activeStory)],
@@ -199,8 +228,8 @@ export function createEditorSurfaceSnapshot(
199
228
 
200
229
  for (let index = 0; index < root.children.length; index += 1) {
201
230
  const isInViewport =
202
- viewportBlockRange === null ||
203
- (index >= viewportBlockRange.start && index < viewportBlockRange.end);
231
+ viewportBlockRanges === null ||
232
+ isIndexInAnyRange(index, viewportBlockRanges);
204
233
  // L7 Phase 2.9 — viewport bail on the style-cascade work. When the
205
234
  // block is outside the viewport, the surface block produced below is
206
235
  // immediately discarded in favor of a `placeholder-culled` entry (we
@@ -293,10 +322,25 @@ export function createEditorSurfaceSnapshot(
293
322
  blocks,
294
323
  lockedFragmentIds,
295
324
  secondaryStories,
296
- viewportBlockRange,
325
+ viewportBlockRanges,
297
326
  };
298
327
  }
299
328
 
329
+ /**
330
+ * True when `index` falls in any of the given half-open intervals. Linear
331
+ * scan because the typical caller passes 1 or 2 ranges; switching to binary
332
+ * search has no measurable payoff below ~8 ranges.
333
+ */
334
+ function isIndexInAnyRange(
335
+ index: number,
336
+ ranges: readonly { start: number; end: number }[],
337
+ ): boolean {
338
+ for (const r of ranges) {
339
+ if (index >= r.start && index < r.end) return true;
340
+ }
341
+ return false;
342
+ }
343
+
300
344
  function createSurfaceBlock(
301
345
  block: BlockNode,
302
346
  document: CanonicalDocumentEnvelope,