@beyondwork/docx-react-component 1.0.72 → 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 +70 -6
- package/src/api/v3/_runtime-handle.ts +15 -0
- package/src/api/v3/ai/policy.ts +31 -0
- package/src/api/v3/ui/_types.ts +21 -0
- package/src/api/v3/ui/chrome-preset-model.ts +6 -0
- package/src/api/v3/ui/overlays.ts +276 -2
- package/src/api/v3/ui/scope.ts +113 -1
- package/src/api/v3/ui/viewport.ts +1 -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/core/state/editor-state.ts +49 -6
- package/src/io/export/export-session.ts +53 -0
- package/src/io/export/serialize-comments.ts +4 -4
- package/src/io/export/serialize-footnotes.ts +6 -0
- package/src/io/export/serialize-headers-footers.ts +6 -0
- package/src/io/export/serialize-main-document.ts +7 -0
- package/src/io/export/serialize-paragraph-formatting.ts +1 -1
- 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/normalize/normalize-text.ts +38 -2
- package/src/io/ooxml/parse-comments.ts +2 -2
- package/src/io/ooxml/parse-headers-footers.ts +31 -0
- package/src/io/ooxml/parse-main-document.ts +127 -2
- package/src/io/ooxml/parse-paragraph-formatting.ts +1 -1
- 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 +30 -1
- package/src/runtime/layout/paginated-layout-engine.ts +47 -0
- package/src/runtime/layout/public-facet.ts +27 -0
- package/src/runtime/scopes/action-validation.ts +30 -4
- package/src/runtime/scopes/evidence.ts +1 -1
- package/src/runtime/scopes/replacement/apply.ts +1 -0
- package/src/runtime/scopes/review-bundle.ts +1 -1
- package/src/runtime/scopes/scope-kinds/paragraph.ts +170 -7
- package/src/runtime/scopes/scope-range.ts +1 -1
- package/src/runtime/scopes/semantic-scope-types.ts +19 -0
- package/src/runtime/selection/post-edit-validator.ts +4 -4
- package/src/runtime/surface-projection.ts +94 -4
- package/src/session/import/loader-types.ts +18 -0
- package/src/session/import/loader.ts +2 -0
- 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 +32 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +6 -0
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +5 -2
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +77 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +12 -4
- package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +49 -32
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +5 -1
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +71 -7
- 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/theme/editor-theme.css +15 -16
- package/src/ui-tailwind/tw-review-workspace.tsx +34 -49
- 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
|
};
|
|
@@ -926,8 +926,37 @@
|
|
|
926
926
|
* Cache envelopes from v53 invalidate because any document with
|
|
927
927
|
* `<w:br w:type="page"/>` (common in CCEP templates with explicit
|
|
928
928
|
* schedule/appendix boundaries) now paginates differently.
|
|
929
|
+
*
|
|
930
|
+
* 55 — coord-04 §1.19.d. L03 (`ca553b1c` 2026-04-23) graduated
|
|
931
|
+
* `SurfaceBlockSnapshot.paragraph.frameProperties` from the canonical
|
|
932
|
+
* `<w:framePr>` model (L01 parse + L02 domain shape already shipped).
|
|
933
|
+
* L04 now honors it: paragraphs carrying out-of-flow frame properties
|
|
934
|
+
* (ECMA-376 §17.3.1.11 — `hAnchor` / `vAnchor` / `xAlign` / `yAlign` /
|
|
935
|
+
* `xTwips` / `yTwips` positioning with text wrapping around) return
|
|
936
|
+
* 0 from `measureBlockHeight` so the pagination flow does not
|
|
937
|
+
* double-count them. The `dropCap="drop"` / `dropCap="margin"` case
|
|
938
|
+
* is excluded — those frame only the initial letter, leaving the
|
|
939
|
+
* rest of the paragraph in the main flow. L11 owns the absolute-
|
|
940
|
+
* positioned render.
|
|
941
|
+
*
|
|
942
|
+
* CCEP parity-lock corpus unaffected: no body-level `<w:framePr>`
|
|
943
|
+
* exists in the 6-doc lock (`EnvelopeAddress` style carries a
|
|
944
|
+
* framePr in `EU & Global Consultancy Services Agreement` but is
|
|
945
|
+
* never referenced by a paragraph). Change is a forward-looking
|
|
946
|
+
* correctness fix for the cross-layer chain now that L01→L02→L03→L04
|
|
947
|
+
* all emit/honor framePr end-to-end. Cache envelopes from v54
|
|
948
|
+
* invalidate because any future document with a real body framePr
|
|
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.
|
|
929
958
|
*/
|
|
930
|
-
export const LAYOUT_ENGINE_VERSION =
|
|
959
|
+
export const LAYOUT_ENGINE_VERSION = 56 as const;
|
|
931
960
|
|
|
932
961
|
/**
|
|
933
962
|
* Serialization schema version for the LayCache payload (the cache envelope
|
|
@@ -988,6 +988,11 @@ function measureBlockHeight(
|
|
|
988
988
|
const compute = (): number => {
|
|
989
989
|
switch (block.kind) {
|
|
990
990
|
case "paragraph": {
|
|
991
|
+
// §1.19.d — out-of-flow framed paragraphs (ECMA-376 §17.3.1.11)
|
|
992
|
+
// contribute 0 to inline flow height; L11 owns the positioned render.
|
|
993
|
+
if (isOutOfFlowFrame(block.frameProperties)) {
|
|
994
|
+
return 0;
|
|
995
|
+
}
|
|
991
996
|
const formatting = resolveBlockFormatting(block, defaultTabInterval, themeFonts);
|
|
992
997
|
if (formatting) {
|
|
993
998
|
// Provider path: sum per-line heights so canvas-backed measurements
|
|
@@ -1168,6 +1173,17 @@ export function __resolveCellWidth(
|
|
|
1168
1173
|
return resolveCellWidth(gridColumns, startColumn, columnSpan, fallbackColumnWidth, gridScale);
|
|
1169
1174
|
}
|
|
1170
1175
|
|
|
1176
|
+
/**
|
|
1177
|
+
* Exposed for coord §1.19.d framePr unit tests; not part of the
|
|
1178
|
+
* stable surface.
|
|
1179
|
+
*/
|
|
1180
|
+
export function __test_measureBlockHeight(
|
|
1181
|
+
block: SurfaceBlockSnapshot | undefined,
|
|
1182
|
+
columnWidth: number,
|
|
1183
|
+
): number {
|
|
1184
|
+
return measureBlockHeight(block, columnWidth);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1171
1187
|
function resolveCellWidth(
|
|
1172
1188
|
gridColumns: readonly number[],
|
|
1173
1189
|
startColumn: number,
|
|
@@ -2054,6 +2070,37 @@ function currentPageNoteIds(
|
|
|
2054
2070
|
return notes;
|
|
2055
2071
|
}
|
|
2056
2072
|
|
|
2073
|
+
/**
|
|
2074
|
+
* OOXML §17.3.1.11 — `<w:framePr>`.
|
|
2075
|
+
*
|
|
2076
|
+
* A paragraph carrying frame properties is rendered as a text frame:
|
|
2077
|
+
* it is positioned relative to the page/margin/text (per `hAnchor` /
|
|
2078
|
+
* `vAnchor` / `xAlign` / `yAlign` / `xTwips` / `yTwips`) and the main
|
|
2079
|
+
* text flow wraps around it (per `wrap`). Consequently the framed
|
|
2080
|
+
* paragraph's height MUST NOT contribute to inline block-height
|
|
2081
|
+
* accounting on the page — otherwise the paginator double-counts the
|
|
2082
|
+
* frame (once as inline block, once as the positioned render).
|
|
2083
|
+
*
|
|
2084
|
+
* The exception is `dropCap` (`drop` / `margin`): the frame wraps
|
|
2085
|
+
* only the initial letter; the rest of the paragraph remains in the
|
|
2086
|
+
* main flow, so the paragraph must continue to contribute its
|
|
2087
|
+
* (non-dropped) height. `dropCap="none"` or absent is a full-frame.
|
|
2088
|
+
*
|
|
2089
|
+
* L11 owns the absolute-positioned render; L04 only needs to keep
|
|
2090
|
+
* the frame out of the flow accounting.
|
|
2091
|
+
*/
|
|
2092
|
+
function isOutOfFlowFrame(
|
|
2093
|
+
frameProperties:
|
|
2094
|
+
| Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>["frameProperties"]
|
|
2095
|
+
| undefined,
|
|
2096
|
+
): boolean {
|
|
2097
|
+
if (!frameProperties) return false;
|
|
2098
|
+
if (frameProperties.dropCap === "drop" || frameProperties.dropCap === "margin") {
|
|
2099
|
+
return false;
|
|
2100
|
+
}
|
|
2101
|
+
return true;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2057
2104
|
function hasColumnBreak(block: SurfaceBlockSnapshot): boolean {
|
|
2058
2105
|
return block.kind === "paragraph" && block.segments.some(
|
|
2059
2106
|
(segment) =>
|
|
@@ -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
|
},
|
|
@@ -111,6 +111,14 @@ export interface ComposeScopeValidationInputs {
|
|
|
111
111
|
* no blockers / warnings / approval.
|
|
112
112
|
*/
|
|
113
113
|
readonly actionId?: AIAction;
|
|
114
|
+
/**
|
|
115
|
+
* Caller-opt-in preservation policy. Honored at the preservation step
|
|
116
|
+
* only — when `opaqueFragments === true`, opaque-fragment findings
|
|
117
|
+
* downgrade from blockers to warnings (still surfaced on
|
|
118
|
+
* `validation.warnings` + audit). Other policy flags are advisory
|
|
119
|
+
* here; the compile step consumes them to drive per-step behavior.
|
|
120
|
+
*/
|
|
121
|
+
readonly preservePolicy?: ReplacementScope["preserve"];
|
|
114
122
|
}
|
|
115
123
|
|
|
116
124
|
/**
|
|
@@ -240,6 +248,7 @@ function collectGuardVerdict(
|
|
|
240
248
|
function collectPreservationVerdict(
|
|
241
249
|
inputs: ComposeScopeValidationInputs,
|
|
242
250
|
blockedReasons: string[],
|
|
251
|
+
warnings: ValidationIssue[],
|
|
243
252
|
): void {
|
|
244
253
|
const { document, scope, positionMap } = inputs;
|
|
245
254
|
if (!document) return;
|
|
@@ -250,10 +259,27 @@ function collectPreservationVerdict(
|
|
|
250
259
|
? (pm.markerScopes.get(scope.handle.stableRef.value) ?? null)
|
|
251
260
|
: null;
|
|
252
261
|
const verdict = computePreservationVerdict(document, range, pm);
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
|
|
262
|
+
if (verdict.replaceable) return;
|
|
263
|
+
const opaqueOptIn = inputs.preservePolicy?.opaqueFragments === true;
|
|
264
|
+
for (const reason of verdict.reasons) {
|
|
265
|
+
const code = `preserve:${reason}`;
|
|
266
|
+
// Opt-in downgrade (2026-04-24): opaque-fragment reasons move from
|
|
267
|
+
// blockers → warnings when the caller opted in via
|
|
268
|
+
// `preservePolicy.opaqueFragments === true`. Scope-marker-inside
|
|
269
|
+
// reasons stay as blockers — they'd require the sibling scope to be
|
|
270
|
+
// destroyed, which is a different kind of safety (scope-identity,
|
|
271
|
+
// not preserve-only payload). Other preserve reasons keep blocker
|
|
272
|
+
// semantics until they grow their own opt-in knob.
|
|
273
|
+
if (opaqueOptIn && reason.startsWith("opaque-fragment:")) {
|
|
274
|
+
warnings.push({
|
|
275
|
+
code,
|
|
276
|
+
message:
|
|
277
|
+
"Opaque fragment present in target range; caller opted in to preserve — compile will narrow the replace range to text-only.",
|
|
278
|
+
source: "preserve",
|
|
279
|
+
});
|
|
280
|
+
continue;
|
|
256
281
|
}
|
|
282
|
+
blockedReasons.push(code);
|
|
257
283
|
}
|
|
258
284
|
}
|
|
259
285
|
|
|
@@ -339,7 +365,7 @@ export function composeScopeValidation(
|
|
|
339
365
|
const warnings: ValidationIssue[] = [];
|
|
340
366
|
|
|
341
367
|
collectGuardVerdict(inputs.scope, inputs.runtime, blockedReasons, warnings);
|
|
342
|
-
collectPreservationVerdict(inputs, blockedReasons);
|
|
368
|
+
collectPreservationVerdict(inputs, blockedReasons, warnings);
|
|
343
369
|
collectCompatibilityVerdict(inputs.runtime, blockedReasons, warnings);
|
|
344
370
|
|
|
345
371
|
const actionId =
|
|
@@ -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":
|
|
@@ -147,6 +147,7 @@ export function applyScopeReplacement(
|
|
|
147
147
|
document: docBefore,
|
|
148
148
|
enumeratedScope: resolvedEnumerated,
|
|
149
149
|
...(inputs.actionId ? { actionId: inputs.actionId } : {}),
|
|
150
|
+
...(proposed.preserve ? { preservePolicy: proposed.preserve } : {}),
|
|
150
151
|
});
|
|
151
152
|
|
|
152
153
|
if (!verdict.safe) {
|
|
@@ -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":
|