@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.
- package/package.json +1 -1
- package/src/api/anchor-conversion.ts +2 -2
- package/src/api/public-types.ts +33 -6
- package/src/api/v3/_runtime-handle.ts +15 -0
- 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/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 +205 -37
- 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 +39 -4
- 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/surface-build-keys.ts +5 -2
- 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 +8 -8
- package/src/ui-tailwind/tw-review-workspace.tsx +13 -35
- 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,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
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
2245
|
-
*
|
|
2246
|
-
*
|
|
2247
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
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
|
-
|
|
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.
|
|
5672
|
-
prevAnchor.
|
|
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.
|
|
6042
|
-
to: Math.max(anchor.
|
|
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.
|
|
7383
|
+
if (mapped.from !== range.start || mapped.to !== range.end) {
|
|
7216
7384
|
changed = true;
|
|
7217
7385
|
return {
|
|
7218
7386
|
...range,
|
|
7219
|
-
start: mapped.
|
|
7220
|
-
end: mapped.
|
|
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.
|
|
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.
|
|
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,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
|
|
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
|
-
|
|
203
|
-
(index
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
269
|
-
(anchor.
|
|
270
|
-
anchor.
|
|
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.
|
|
280
|
-
anchor.
|
|
279
|
+
anchor.from >= boundary.start &&
|
|
280
|
+
anchor.to <= boundary.end,
|
|
281
281
|
);
|
|
282
282
|
return paragraphBoundary
|
|
283
283
|
? undefined
|