@beyondwork/docx-react-component 1.0.73 → 1.0.74

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/package.json +1 -1
  2. package/src/api/anchor-conversion.ts +2 -2
  3. package/src/api/public-types.ts +33 -6
  4. package/src/api/v3/_runtime-handle.ts +15 -0
  5. package/src/api/v3/ui/_types.ts +21 -0
  6. package/src/api/v3/ui/overlays.ts +276 -2
  7. package/src/api/v3/ui/scope.ts +113 -1
  8. package/src/compare/diff-engine.ts +1 -2
  9. package/src/core/commands/index.ts +14 -15
  10. package/src/core/selection/anchor-conversion.ts +2 -2
  11. package/src/core/selection/mapping.ts +10 -8
  12. package/src/core/selection/review-anchors.ts +3 -3
  13. package/src/io/export/export-session.ts +53 -0
  14. package/src/io/export/serialize-comments.ts +4 -4
  15. package/src/io/export/serialize-runtime-revisions.ts +10 -10
  16. package/src/io/export/split-review-boundaries.ts +4 -4
  17. package/src/io/export/split-story-blocks-for-runtime-revisions.ts +2 -2
  18. package/src/io/ooxml/parse-comments.ts +2 -2
  19. package/src/model/anchor.ts +9 -1
  20. package/src/model/canonical-document.ts +76 -3
  21. package/src/preservation/store.ts +24 -0
  22. package/src/review/store/comment-anchors.ts +1 -1
  23. package/src/review/store/comment-remapping.ts +1 -1
  24. package/src/review/store/revision-actions.ts +4 -4
  25. package/src/review/store/revision-types.ts +1 -1
  26. package/src/review/store/scope-tag-diff.ts +1 -1
  27. package/src/runtime/collab/map-local-selection-on-remote-replay.ts +7 -7
  28. package/src/runtime/document-runtime.ts +205 -37
  29. package/src/runtime/formatting/formatting-context.ts +1 -1
  30. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  31. package/src/runtime/layout/layout-engine-version.ts +9 -1
  32. package/src/runtime/layout/public-facet.ts +27 -0
  33. package/src/runtime/scopes/evidence.ts +1 -1
  34. package/src/runtime/scopes/review-bundle.ts +1 -1
  35. package/src/runtime/scopes/scope-range.ts +1 -1
  36. package/src/runtime/selection/post-edit-validator.ts +4 -4
  37. package/src/runtime/surface-projection.ts +39 -4
  38. package/src/session/import/review-import.ts +12 -12
  39. package/src/session/import/workflow-scope-import.ts +9 -8
  40. package/src/shell/session-bootstrap.ts +4 -0
  41. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +5 -2
  42. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +99 -43
  43. package/src/ui-tailwind/review-workspace/use-page-markers.ts +48 -7
  44. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +8 -8
  45. package/src/ui-tailwind/tw-review-workspace.tsx +13 -35
  46. package/src/validation/compatibility-engine.ts +1 -1
  47. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +0 -114
  48. 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,46 @@ 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
+ if (next.start <= last.end) {
1040
+ if (next.end > last.end) last.end = next.end;
1041
+ } else {
1042
+ merged.push(next);
1043
+ }
1044
+ }
1045
+ return Object.freeze(merged.map((r) => Object.freeze(r)));
1046
+ }
1047
+
1048
+ /** Stable serialization of normalized viewport ranges for fingerprinting. */
1049
+ function serializeViewportRanges(
1050
+ ranges: readonly { start: number; end: number }[] | null,
1051
+ ): string {
1052
+ if (ranges === null) return "null";
1053
+ if (ranges.length === 0) return "empty";
1054
+ return ranges.map((r) => `${r.start}:${r.end}`).join("|");
1055
+ }
1056
+
962
1057
  function computeWorkflowMarkupStructuralHash(
963
1058
  wfMarkup: WorkflowMarkupSnapshot,
964
1059
  ): string {
@@ -1215,19 +1310,14 @@ export function createDocumentRuntime(
1215
1310
  // so the facet forwards to the runtime method. Forward-reference pattern matches
1216
1311
  // `renderKernelRef` — see that example. See spec-review notes on commit 0b03bfa7.
1217
1312
  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");
1313
+ applyViewportRanges([range]);
1314
+ },
1315
+ setVisibleBlockRanges: (ranges) => {
1316
+ applyViewportRanges(ranges);
1227
1317
  },
1228
1318
  requestViewportRefresh: () => {
1229
1319
  perfCounters.increment("runtime.viewport.refreshes");
1230
- refreshSurfaceOnly();
1320
+ maybeRefreshSurfaceForViewport();
1231
1321
  },
1232
1322
  });
1233
1323
  // D1 — wire the layout-guard warning emitter so `return []` guard sites
@@ -1255,8 +1345,28 @@ export function createDocumentRuntime(
1255
1345
  renderKernel: () => renderKernelRef,
1256
1346
  getCanonicalDocument: () => state.document,
1257
1347
  });
1258
- // L7 Phase 2 — viewport block range for surface culling.
1259
- let viewportBlockRange: { start: number; end: number } | null = null;
1348
+ // L7 Phase 2 — viewport block range(s) for surface culling. Multi-interval
1349
+ // canonical state: a block is real if its index falls in ANY interval.
1350
+ // Stored as a frozen, sorted, non-overlapping list so `applyViewportRanges`
1351
+ // can identity-compare for fast no-ops; `null` = "all blocks real" (legacy).
1352
+ let viewportBlockRanges: readonly { start: number; end: number }[] | null = null;
1353
+ /** Serialized fingerprint of the active ranges — used as the idempotency key for `refreshSurfaceOnly`. */
1354
+ let viewportRangesKey: string = serializeViewportRanges(null);
1355
+
1356
+ function applyViewportRanges(
1357
+ incoming: readonly { start: number; end: number }[] | null,
1358
+ ): void {
1359
+ // Normalize: empty array / null both mean "no culling".
1360
+ const normalized =
1361
+ incoming == null || incoming.length === 0
1362
+ ? null
1363
+ : normalizeViewportRanges(incoming);
1364
+ const nextKey = serializeViewportRanges(normalized);
1365
+ if (nextKey === viewportRangesKey) return;
1366
+ viewportBlockRanges = normalized;
1367
+ viewportRangesKey = nextKey;
1368
+ perfCounters.increment("runtime.viewport.updates");
1369
+ }
1260
1370
 
1261
1371
  let cachedSurface:
1262
1372
  | {
@@ -1429,7 +1539,7 @@ export function createDocumentRuntime(
1429
1539
  }
1430
1540
 
1431
1541
  const snapshot = createEditorSurfaceSnapshot(document, state.selection, nextActiveStory, {
1432
- viewportBlockRange,
1542
+ viewportBlockRanges,
1433
1543
  ...(effectiveMarkupModeProvider
1434
1544
  ? { getEffectiveMarkupMode: effectiveMarkupModeProvider }
1435
1545
  : {}),
@@ -2241,10 +2351,28 @@ export function createDocumentRuntime(
2241
2351
  }
2242
2352
 
2243
2353
  /**
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".
2354
+ * Fingerprint over the inputs that the viewport-refresh fast-path knows
2355
+ * about. Used to short-circuit {@link maybeRefreshSurfaceForViewport}
2356
+ * when nothing that affects the scroll-driven projection has changed
2357
+ * since the last build. Scroll handlers fire effectively-identical
2358
+ * requests on every tick (IO coalescing, overscan nudges that don't
2359
+ * flip any page's visible state); without this gate every tick would
2360
+ * rebuild the surface + notify listeners + force PM `view.updateState()`.
2361
+ *
2362
+ * NOT suitable for callers that invalidate external projection inputs
2363
+ * (markup-mode provider, etc.) because those inputs are read at
2364
+ * projection time and are not captured in the fingerprint. Those callers
2365
+ * use {@link refreshSurfaceOnly} directly (unconditional rebuild).
2366
+ */
2367
+ let cachedSurfaceFingerprint: string | null = null;
2368
+
2369
+ /**
2370
+ * L7 Phase 2 — surface-only refresh. Rebuilds the surface facet (with
2371
+ * the current viewportBlockRanges) and splices it into
2372
+ * cachedRenderSnapshot. Does NOT rebuild layout, comments, trackedChanges,
2373
+ * or compatibility. Does NOT increment "refresh.all". Callers that
2374
+ * know an input outside the fingerprint changed (markup-mode provider,
2375
+ * etc.) use this directly.
2248
2376
  */
2249
2377
  function refreshSurfaceOnly(): void {
2250
2378
  const newSurface = createEditorSurfaceSnapshot(
@@ -2252,12 +2380,13 @@ export function createDocumentRuntime(
2252
2380
  state.selection,
2253
2381
  activeStory,
2254
2382
  {
2255
- viewportBlockRange,
2383
+ viewportBlockRanges,
2256
2384
  ...(effectiveMarkupModeProvider
2257
2385
  ? { getEffectiveMarkupMode: effectiveMarkupModeProvider }
2258
2386
  : {}),
2259
2387
  },
2260
2388
  );
2389
+ cachedSurfaceFingerprint = `${state.revisionToken}|${storyTargetKey(activeStory)}|${viewportRangesKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
2261
2390
  // Refresh the cache with the just-built snapshot so a subsequent
2262
2391
  // refreshRenderSnapshot (same revisionToken + activeStoryKey) hits cache.
2263
2392
  // Without this repopulate, the next non-mutation refresh wastes a second
@@ -2277,6 +2406,25 @@ export function createDocumentRuntime(
2277
2406
  }
2278
2407
  }
2279
2408
 
2409
+ /**
2410
+ * Viewport-refresh fast path — short-circuits when the tracked
2411
+ * fingerprint matches, otherwise delegates to {@link refreshSurfaceOnly}.
2412
+ * Used by `requestViewportRefresh` so steady-state scroll doesn't pay
2413
+ * a full projection + listener fan-out on every tick.
2414
+ *
2415
+ * Safe because every external input that can change the projection
2416
+ * output without ticking revisionToken / selection / viewportRanges /
2417
+ * activeStory is wired to call `refreshSurfaceOnly` directly (see
2418
+ * `setEffectiveMarkupModeProvider`, `invalidateForMarkupModeChange`).
2419
+ */
2420
+ function maybeRefreshSurfaceForViewport(): void {
2421
+ const fingerprint = `${state.revisionToken}|${storyTargetKey(activeStory)}|${viewportRangesKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
2422
+ if (cachedSurfaceFingerprint === fingerprint && cachedSurface) {
2423
+ return;
2424
+ }
2425
+ refreshSurfaceOnly();
2426
+ }
2427
+
2280
2428
  function invalidateDerivedRuntimeCaches(): void {
2281
2429
  cachedSurface = undefined;
2282
2430
  cachedCompatibility = undefined;
@@ -3289,6 +3437,31 @@ export function createDocumentRuntime(
3289
3437
  compileScopeBundleById(scopeId, nowUtc) {
3290
3438
  return createScopeCompilerService(runtime).compileBundleById(scopeId, nowUtc);
3291
3439
  },
3440
+ compileScopeList(filter) {
3441
+ const all = createScopeCompilerService(runtime).compileAllScopes();
3442
+ const byKind = filter?.kind
3443
+ ? all.filter((s) => s.kind === filter.kind)
3444
+ : all.slice();
3445
+ if (typeof filter?.limit === "number") {
3446
+ return byKind.slice(0, filter.limit);
3447
+ }
3448
+ return byKind;
3449
+ },
3450
+ compileScopeCardById(scopeId) {
3451
+ const cards = workflowCoordinator.getAllScopeCardModels();
3452
+ return cards.find((card) => card.scopeId === scopeId) ?? null;
3453
+ },
3454
+ compileScopeRailSnapshot(options) {
3455
+ const pageIndex = options?.pageIndex;
3456
+ const segments =
3457
+ typeof pageIndex === "number"
3458
+ ? workflowCoordinator.getRailSegments(pageIndex)
3459
+ : workflowCoordinator.getAllRailSegments();
3460
+ return {
3461
+ segments: Object.freeze([...segments]),
3462
+ ...(typeof pageIndex === "number" ? { pageIndex } : {}),
3463
+ };
3464
+ },
3292
3465
  getMarkerBackedScopeIds() {
3293
3466
  return workflowCoordinator.getMarkerBackedScopeIds();
3294
3467
  },
@@ -4219,19 +4392,14 @@ export function createDocumentRuntime(
4219
4392
  perfCounters.reset();
4220
4393
  },
4221
4394
  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");
4395
+ applyViewportRanges([range]);
4396
+ },
4397
+ setVisibleBlockRanges(ranges) {
4398
+ applyViewportRanges(ranges);
4231
4399
  },
4232
4400
  requestViewportRefresh() {
4233
4401
  perfCounters.increment("runtime.viewport.refreshes");
4234
- refreshSurfaceOnly();
4402
+ maybeRefreshSurfaceForViewport();
4235
4403
  },
4236
4404
  };
4237
4405
 
@@ -5668,8 +5836,8 @@ function semanticallyEqualCommentThreads(
5668
5836
  if (prevAnchor.kind !== nextAnchor.kind) return false;
5669
5837
  if (prevAnchor.kind === "range" && nextAnchor.kind === "range") {
5670
5838
  return (
5671
- prevAnchor.range.from === nextAnchor.range.from &&
5672
- prevAnchor.range.to === nextAnchor.range.to
5839
+ prevAnchor.from === nextAnchor.from &&
5840
+ prevAnchor.to === nextAnchor.to
5673
5841
  );
5674
5842
  }
5675
5843
  if (prevAnchor.kind === "node" && nextAnchor.kind === "node") {
@@ -6038,8 +6206,8 @@ function toAnchorBounds(anchor: InternalEditorAnchorProjection): { from: number;
6038
6206
  switch (anchor.kind) {
6039
6207
  case "range":
6040
6208
  return {
6041
- from: Math.min(anchor.range.from, anchor.range.to),
6042
- to: Math.max(anchor.range.from, anchor.range.to),
6209
+ from: Math.min(anchor.from, anchor.to),
6210
+ to: Math.max(anchor.from, anchor.to),
6043
6211
  };
6044
6212
  case "node":
6045
6213
  return {
@@ -7212,12 +7380,12 @@ function remapProtectionSnapshot(
7212
7380
  "preserve-only: permission range could not be remapped after runtime edits",
7213
7381
  };
7214
7382
  }
7215
- if (mapped.range.from !== range.start || mapped.range.to !== range.end) {
7383
+ if (mapped.from !== range.start || mapped.to !== range.end) {
7216
7384
  changed = true;
7217
7385
  return {
7218
7386
  ...range,
7219
- start: mapped.range.from,
7220
- end: mapped.range.to,
7387
+ start: mapped.from,
7388
+ end: mapped.to,
7221
7389
  };
7222
7390
  }
7223
7391
  return range;
@@ -7366,7 +7534,7 @@ function resolveClearHighlightRange(
7366
7534
  }
7367
7535
  const active = selection.activeRange;
7368
7536
  if (active.kind !== "range") return null;
7369
- return { from: active.range.from, to: active.range.to };
7537
+ return { from: active.from, to: active.to };
7370
7538
  }
7371
7539
 
7372
7540
  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,20 @@ 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
+ viewportBlockRanges?: readonly { start: number; end: number }[] | null;
111
+ /** @deprecated use `viewportBlockRanges`. Kept for back-compat; wrapped into a 1-element array when supplied alone. */
98
112
  viewportBlockRange?: { start: number; end: number } | null;
99
113
  /**
100
114
  * Active markup mode. When set together with the document's
@@ -146,7 +160,13 @@ export function createEditorSurfaceSnapshot(
146
160
  activeStory: EditorStoryTarget = { kind: "main" },
147
161
  options: SurfaceProjectionOptions = {},
148
162
  ): EditorSurfaceSnapshot {
149
- const viewportBlockRange = options.viewportBlockRange ?? null;
163
+ const viewportBlockRanges: readonly { start: number; end: number }[] | null = (() => {
164
+ if (options.viewportBlockRanges != null) {
165
+ return options.viewportBlockRanges.length === 0 ? null : options.viewportBlockRanges;
166
+ }
167
+ if (options.viewportBlockRange != null) return [options.viewportBlockRange];
168
+ return null;
169
+ })();
150
170
  const root = normalizeDocumentRoot({
151
171
  type: "doc",
152
172
  children: [...getStoryBlocks(document, activeStory)],
@@ -199,8 +219,8 @@ export function createEditorSurfaceSnapshot(
199
219
 
200
220
  for (let index = 0; index < root.children.length; index += 1) {
201
221
  const isInViewport =
202
- viewportBlockRange === null ||
203
- (index >= viewportBlockRange.start && index < viewportBlockRange.end);
222
+ viewportBlockRanges === null ||
223
+ isIndexInAnyRange(index, viewportBlockRanges);
204
224
  // L7 Phase 2.9 — viewport bail on the style-cascade work. When the
205
225
  // block is outside the viewport, the surface block produced below is
206
226
  // immediately discarded in favor of a `placeholder-culled` entry (we
@@ -293,10 +313,25 @@ export function createEditorSurfaceSnapshot(
293
313
  blocks,
294
314
  lockedFragmentIds,
295
315
  secondaryStories,
296
- viewportBlockRange,
316
+ viewportBlockRanges,
297
317
  };
298
318
  }
299
319
 
320
+ /**
321
+ * True when `index` falls in any of the given half-open intervals. Linear
322
+ * scan because the typical caller passes 1 or 2 ranges; switching to binary
323
+ * search has no measurable payoff below ~8 ranges.
324
+ */
325
+ function isIndexInAnyRange(
326
+ index: number,
327
+ ranges: readonly { start: number; end: number }[],
328
+ ): boolean {
329
+ for (const r of ranges) {
330
+ if (index >= r.start && index < r.end) return true;
331
+ }
332
+ return false;
333
+ }
334
+
300
335
  function createSurfaceBlock(
301
336
  block: BlockNode,
302
337
  document: CanonicalDocumentEnvelope,
@@ -82,7 +82,7 @@ export function normalizeImportedRevisionRecords(
82
82
 
83
83
  const preserveOnlyReason =
84
84
  getStructuralPreserveOnlyReason(revision, paragraphRanges) ??
85
- (opaqueRanges.some((range) => rangesIntersect(range, anchor.range))
85
+ (opaqueRanges.some((range) => rangesIntersect(range, { from: anchor.from, to: anchor.to }))
86
86
  ? "Imported revision overlaps preserve-only OOXML and remains preserve-only."
87
87
  : undefined);
88
88
 
@@ -118,7 +118,7 @@ export function normalizeImportedCommentThreads(
118
118
  ) {
119
119
  return [];
120
120
  }
121
- return [revision.anchor.range];
121
+ return [{ from: revision.anchor.from, to: revision.anchor.to }];
122
122
  });
123
123
  const preserveOnlyCommentIds = new Set(parsed.diagnostics.map((diagnostic) => diagnostic.commentId));
124
124
  const additionalDiagnostics: CommentImportDiagnostic[] = [];
@@ -129,7 +129,7 @@ export function normalizeImportedCommentThreads(
129
129
  return thread;
130
130
  }
131
131
 
132
- const opaqueOverlap = opaqueRanges.some((range) => rangesIntersect(range, anchor.range));
132
+ const opaqueOverlap = opaqueRanges.some((range) => rangesIntersect(range, { from: anchor.from, to: anchor.to }));
133
133
  if (opaqueOverlap) {
134
134
  preserveOnlyCommentIds.add(thread.commentId);
135
135
  additionalDiagnostics.push({
@@ -143,7 +143,7 @@ export function normalizeImportedCommentThreads(
143
143
  });
144
144
  return {
145
145
  ...thread,
146
- anchor: createDetachedAnchor(anchor.range, "importAmbiguity"),
146
+ anchor: createDetachedAnchor({ from: anchor.from, to: anchor.to }, "importAmbiguity"),
147
147
  status: "detached",
148
148
  metadata: {
149
149
  ...thread.metadata,
@@ -155,7 +155,7 @@ export function normalizeImportedCommentThreads(
155
155
  }
156
156
 
157
157
  const preserveOnlyRevisionOverlap = preserveOnlyRevisionRanges.some((range) =>
158
- rangesIntersect(range, anchor.range),
158
+ rangesIntersect(range, { from: anchor.from, to: anchor.to }),
159
159
  );
160
160
  if (preserveOnlyRevisionOverlap) {
161
161
  preserveOnlyCommentIds.add(thread.commentId);
@@ -170,7 +170,7 @@ export function normalizeImportedCommentThreads(
170
170
  });
171
171
  return {
172
172
  ...thread,
173
- anchor: createDetachedAnchor(anchor.range, "importAmbiguity"),
173
+ anchor: createDetachedAnchor({ from: anchor.from, to: anchor.to }, "importAmbiguity"),
174
174
  status: "detached",
175
175
  metadata: {
176
176
  ...thread.metadata,
@@ -257,7 +257,7 @@ export function getStructuralPreserveOnlyReason(
257
257
 
258
258
  if (
259
259
  (form === "run-insertion" || form === "run-deletion") &&
260
- anchor.range.from === anchor.range.to
260
+ anchor.from === anchor.to
261
261
  ) {
262
262
  return "Imported zero-width run revision remains preserve-only.";
263
263
  }
@@ -265,9 +265,9 @@ export function getStructuralPreserveOnlyReason(
265
265
  if (form === "paragraph-insertion" || form === "paragraph-deletion") {
266
266
  const paragraphBoundary = paragraphRanges.find(
267
267
  (boundary) =>
268
- boundary.end === anchor.range.from ||
269
- (anchor.range.from >= boundary.start &&
270
- anchor.range.from <= boundary.end),
268
+ boundary.end === anchor.from ||
269
+ (anchor.from >= boundary.start &&
270
+ anchor.from <= boundary.end),
271
271
  );
272
272
  return paragraphBoundary
273
273
  ? undefined
@@ -276,8 +276,8 @@ export function getStructuralPreserveOnlyReason(
276
276
 
277
277
  const paragraphBoundary = paragraphRanges.find(
278
278
  (boundary) =>
279
- anchor.range.from >= boundary.start &&
280
- anchor.range.to <= boundary.end,
279
+ anchor.from >= boundary.start &&
280
+ anchor.to <= boundary.end,
281
281
  );
282
282
  return paragraphBoundary
283
283
  ? undefined