@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.
- package/package.json +1 -1
- package/src/api/anchor-conversion.ts +2 -2
- package/src/api/public-types.ts +40 -6
- package/src/api/v3/_runtime-handle.ts +15 -0
- package/src/api/v3/runtime/workflow.ts +130 -1
- package/src/api/v3/ui/_types.ts +21 -0
- package/src/api/v3/ui/overlays.ts +276 -2
- package/src/api/v3/ui/scope.ts +113 -1
- package/src/compare/diff-engine.ts +1 -2
- package/src/core/commands/index.ts +14 -15
- package/src/core/selection/anchor-conversion.ts +2 -2
- package/src/core/selection/mapping.ts +10 -8
- package/src/core/selection/review-anchors.ts +3 -3
- package/src/io/export/export-session.ts +53 -0
- package/src/io/export/serialize-comments.ts +4 -4
- package/src/io/export/serialize-runtime-revisions.ts +10 -10
- package/src/io/export/split-review-boundaries.ts +4 -4
- package/src/io/export/split-story-blocks-for-runtime-revisions.ts +2 -2
- package/src/io/ooxml/parse-comments.ts +2 -2
- package/src/io/ooxml/parse-headers-footers.ts +7 -13
- package/src/io/ooxml/parse-main-document.ts +7 -31
- package/src/io/ooxml/table-opaque-preservation.ts +171 -0
- package/src/model/anchor.ts +9 -1
- package/src/model/canonical-document.ts +76 -3
- package/src/preservation/store.ts +24 -0
- package/src/review/store/comment-anchors.ts +1 -1
- package/src/review/store/comment-remapping.ts +1 -1
- package/src/review/store/revision-actions.ts +4 -4
- package/src/review/store/revision-types.ts +1 -1
- package/src/review/store/scope-tag-diff.ts +1 -1
- package/src/runtime/collab/map-local-selection-on-remote-replay.ts +7 -7
- package/src/runtime/document-runtime.ts +233 -38
- package/src/runtime/formatting/formatting-context.ts +1 -1
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +9 -1
- package/src/runtime/layout/public-facet.ts +27 -0
- package/src/runtime/scopes/evidence.ts +1 -1
- package/src/runtime/scopes/review-bundle.ts +1 -1
- package/src/runtime/scopes/scope-range.ts +1 -1
- package/src/runtime/selection/post-edit-validator.ts +4 -4
- package/src/runtime/surface-projection.ts +48 -4
- package/src/runtime/workflow/scope-writer.ts +212 -10
- package/src/session/import/review-import.ts +12 -12
- package/src/session/import/workflow-scope-import.ts +9 -8
- package/src/shell/session-bootstrap.ts +4 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +22 -2
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +5 -2
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +22 -3
- package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +7 -3
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +7 -5
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +99 -43
- package/src/ui-tailwind/review-workspace/use-page-markers.ts +48 -7
- package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +0 -13
- package/src/ui-tailwind/tw-review-workspace.tsx +13 -41
- package/src/validation/compatibility-engine.ts +1 -1
- package/src/ui-tailwind/chrome/tw-layout-panel.tsx +0 -114
- 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 {
|
|
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
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
2245
|
-
*
|
|
2246
|
-
*
|
|
2247
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
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
|
-
|
|
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.
|
|
5672
|
-
prevAnchor.
|
|
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.
|
|
6042
|
-
to: Math.max(anchor.
|
|
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.
|
|
7410
|
+
if (mapped.from !== range.start || mapped.to !== range.end) {
|
|
7216
7411
|
changed = true;
|
|
7217
7412
|
return {
|
|
7218
7413
|
...range,
|
|
7219
|
-
start: mapped.
|
|
7220
|
-
end: mapped.
|
|
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.
|
|
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.
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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",
|
|
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
|
|
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
|
-
|
|
203
|
-
(index
|
|
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
|
-
|
|
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,
|