@beyondwork/docx-react-component 1.0.35 → 1.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +103 -13
  2. package/package.json +1 -1
  3. package/src/api/package-version.ts +13 -0
  4. package/src/api/public-types.ts +84 -1
  5. package/src/core/commands/index.ts +19 -2
  6. package/src/core/selection/mapping.ts +6 -0
  7. package/src/io/docx-session.ts +24 -9
  8. package/src/io/export/build-app-properties-xml.ts +88 -0
  9. package/src/io/export/serialize-comments.ts +6 -1
  10. package/src/io/export/serialize-footnotes.ts +10 -9
  11. package/src/io/export/serialize-headers-footers.ts +11 -10
  12. package/src/io/export/serialize-main-document.ts +337 -50
  13. package/src/io/export/serialize-numbering.ts +115 -24
  14. package/src/io/export/serialize-tables.ts +13 -11
  15. package/src/io/export/table-properties-xml.ts +35 -16
  16. package/src/io/export/twip.ts +66 -0
  17. package/src/io/normalize/normalize-text.ts +5 -0
  18. package/src/io/ooxml/parse-footnotes.ts +2 -1
  19. package/src/io/ooxml/parse-headers-footers.ts +2 -1
  20. package/src/io/ooxml/parse-main-document.ts +21 -1
  21. package/src/legal/bookmarks.ts +78 -0
  22. package/src/model/canonical-document.ts +11 -0
  23. package/src/review/store/scope-tag-diff.ts +130 -0
  24. package/src/runtime/document-navigation.ts +1 -305
  25. package/src/runtime/document-runtime.ts +178 -16
  26. package/src/runtime/layout/docx-font-loader.ts +143 -0
  27. package/src/runtime/layout/index.ts +188 -0
  28. package/src/runtime/layout/inert-layout-facet.ts +45 -0
  29. package/src/runtime/layout/layout-engine-instance.ts +618 -0
  30. package/src/runtime/layout/layout-invalidation.ts +257 -0
  31. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  32. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  33. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  34. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  35. package/src/runtime/layout/page-graph.ts +433 -0
  36. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  37. package/src/runtime/layout/page-story-resolver.ts +195 -0
  38. package/src/runtime/layout/paginated-layout-engine.ts +788 -0
  39. package/src/runtime/layout/public-facet.ts +705 -0
  40. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  41. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  42. package/src/runtime/scope-tag-registry.ts +95 -0
  43. package/src/runtime/session-capabilities.ts +7 -4
  44. package/src/runtime/surface-projection.ts +1 -0
  45. package/src/runtime/text-ack-range.ts +49 -0
  46. package/src/ui/WordReviewEditor.tsx +15 -0
  47. package/src/ui/editor-runtime-boundary.ts +10 -1
  48. package/src/ui/editor-surface-controller.tsx +3 -0
  49. package/src/ui/headless/chrome-registry.ts +235 -0
  50. package/src/ui/headless/scoped-chrome-policy.ts +164 -0
  51. package/src/ui/headless/selection-tool-context.ts +2 -0
  52. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  53. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +333 -0
  54. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +89 -0
  55. package/src/ui-tailwind/editor-surface/perf-probe.ts +21 -1
  56. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +8 -1
  57. package/src/ui-tailwind/editor-surface/pm-decorations.ts +73 -13
  58. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  59. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  60. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  61. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +173 -6
  62. package/src/ui-tailwind/theme/editor-theme.css +40 -14
  63. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  64. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +235 -166
  65. package/src/ui-tailwind/tw-review-workspace.tsx +27 -1
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Inert layout facet used during the runtime-loading phase.
3
+ *
4
+ * Returns empty or null values for every query; `subscribe` is a no-op.
5
+ * The React shell replaces this with the real facet once the runtime is
6
+ * ready, so consumers never see a "missing" facet — only an empty one.
7
+ */
8
+
9
+ import type {
10
+ LayoutFacetEvent,
11
+ PublicFieldDirtinessReport,
12
+ PublicMeasurementFidelity,
13
+ WordReviewEditorLayoutFacet,
14
+ } from "./public-facet.ts";
15
+
16
+ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
17
+ const emptyReport: PublicFieldDirtinessReport = {
18
+ families: [],
19
+ revision: 0,
20
+ };
21
+ const fidelity: PublicMeasurementFidelity = "empirical";
22
+ return {
23
+ getPageCount: () => 0,
24
+ getPage: () => null,
25
+ getPages: () => [],
26
+ getSection: () => null,
27
+ getSections: () => [],
28
+ getPageForOffset: () => null,
29
+ getPageSpanForSelection: () => null,
30
+ getFragmentForOffset: () => null,
31
+ getAnchorForOffset: () => null,
32
+ getActiveStoriesOnPage: () => null,
33
+ getDisplayPageNumber: () => null,
34
+ getLineBoxes: () => [],
35
+ getFragmentsForPage: () => [],
36
+ getResolvedFormatting: () => null,
37
+ getResolvedRunFormatting: () => null,
38
+ getMeasurement: () => null,
39
+ getMeasurementFidelity: () => fidelity,
40
+ whenMeasurementReady: () => Promise.resolve(),
41
+ getDirtyFieldFamilies: () => [],
42
+ getFieldDirtinessReport: () => emptyReport,
43
+ subscribe: (_listener: (event: LayoutFacetEvent) => void) => () => undefined,
44
+ };
45
+ }
@@ -0,0 +1,618 @@
1
+ /**
2
+ * LayoutEngineInstance — the stateful `PaginatedLayoutEngine` the architecture
3
+ * spec describes as `DocumentRuntime`-owned.
4
+ *
5
+ * Wraps the stateless `buildPageStack` pipeline and adds:
6
+ * - cached graph keyed by (content, styles, subParts, viewState)
7
+ * - integrated `PageStoryResolver` on every graph build
8
+ * - integrated invalidation analysis (`analyzeInvalidation`)
9
+ * - field-dirtiness reporting (`computeFieldDirtiness`)
10
+ * - stable access to the enriched page graph + fragment mapper
11
+ * - pluggable `LayoutMeasurementProvider` (empirical by default, canvas on
12
+ * demand)
13
+ *
14
+ * This file intentionally stays additive. It does not refactor the existing
15
+ * `buildPageStack` / `computePageStack` exports — instead it consumes them
16
+ * and layers the graph/resolver/invalidation behavior on top.
17
+ */
18
+
19
+ import type {
20
+ DocumentNavigationSnapshot,
21
+ DocumentPageSnapshot,
22
+ EditorStoryTarget,
23
+ EditorSurfaceSnapshot,
24
+ PageLayoutSnapshot,
25
+ SelectionSnapshot,
26
+ } from "../../api/public-types";
27
+ import type {
28
+ CanonicalDocumentEnvelope,
29
+ } from "../../core/state/editor-state.ts";
30
+ import { MAIN_STORY_TARGET } from "../../core/selection/mapping.ts";
31
+ import { createSelectionSnapshot } from "../../core/state/editor-state.ts";
32
+ import {
33
+ buildResolvedSections,
34
+ findSectionForPosition,
35
+ type ResolvedDocumentSection,
36
+ } from "../document-layout.ts";
37
+ import { findNoteReferencePosition } from "../view-state.ts";
38
+ import { createEditorSurfaceSnapshot } from "../surface-projection.ts";
39
+ import {
40
+ analyzeInvalidation,
41
+ computeFieldDirtiness,
42
+ type InvalidationResult,
43
+ } from "./layout-invalidation.ts";
44
+ import {
45
+ buildPageStack,
46
+ buildPageStackFrom,
47
+ type LayoutInvalidationReason,
48
+ } from "./paginated-layout-engine.ts";
49
+ import {
50
+ buildPageGraph,
51
+ findPageNodeForOffset,
52
+ spliceGraph,
53
+ type RuntimePageGraph,
54
+ type RuntimePageNode,
55
+ } from "./page-graph.ts";
56
+ import {
57
+ resolvePageStories,
58
+ resolveTotalPageCount,
59
+ } from "./page-story-resolver.ts";
60
+ import {
61
+ deriveActivePage,
62
+ deriveActivePageIndex,
63
+ deriveActiveSectionIndex,
64
+ deriveDocumentPageSnapshots,
65
+ derivePageLayoutSnapshotFromGraph,
66
+ } from "./page-layout-snapshot-adapter.ts";
67
+ import {
68
+ buildResolvedFormattingState,
69
+ type ResolvedFormattingState,
70
+ } from "./resolved-formatting-document.ts";
71
+ import {
72
+ createPageFragmentMapper,
73
+ rebuildMapper,
74
+ type PageFragmentMapper,
75
+ } from "./page-fragment-mapper.ts";
76
+ import {
77
+ createEmpiricalMeasurementProvider,
78
+ type LayoutMeasurementProvider,
79
+ } from "./layout-measurement-provider.ts";
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Types
83
+ // ---------------------------------------------------------------------------
84
+
85
+ export interface LayoutEngineViewState {
86
+ /** Active story that scopes story-aware queries. */
87
+ activeStory?: EditorStoryTarget;
88
+ workspaceMode?: "canvas" | "page";
89
+ zoomLevel?: number | "pageWidth" | "onePage";
90
+ }
91
+
92
+ export interface LayoutEngineQueryInput {
93
+ document: CanonicalDocumentEnvelope;
94
+ viewState?: LayoutEngineViewState;
95
+ }
96
+
97
+ export interface LayoutEngineEvent {
98
+ kind:
99
+ | "layout_recomputed"
100
+ | "incremental_relayout"
101
+ | "page_count_changed"
102
+ | "page_field_dirtied"
103
+ | "measurement_backend_ready";
104
+ revision: number;
105
+ previousPageCount?: number;
106
+ currentPageCount?: number;
107
+ dirtyFieldFamilies?: readonly string[];
108
+ reason?: LayoutInvalidationReason;
109
+ fidelity?: LayoutMeasurementProvider["fidelity"];
110
+ /** First dirty page index for incremental_relayout events. */
111
+ firstDirtyPageIndex?: number;
112
+ }
113
+
114
+ export interface LayoutEngineInstance {
115
+ /** Current measurement provider fidelity. */
116
+ readonly measurementFidelity: LayoutMeasurementProvider["fidelity"];
117
+ /** Await until the measurement provider is fully ready. */
118
+ whenMeasurementReady(): Promise<void>;
119
+
120
+ // ---- graph + snapshots ------------------------------------------------
121
+ getPageGraph(input: LayoutEngineQueryInput): RuntimePageGraph;
122
+ getResolvedFormattingState(input: LayoutEngineQueryInput): ResolvedFormattingState;
123
+ getPageStack(input: LayoutEngineQueryInput): DocumentPageSnapshot[];
124
+ getPageLayoutSnapshot(
125
+ input: LayoutEngineQueryInput,
126
+ sectionIndex: number,
127
+ ): PageLayoutSnapshot | null;
128
+ getNavigationSnapshot(
129
+ input: LayoutEngineQueryInput,
130
+ selection: SelectionSnapshot,
131
+ activeStory: EditorStoryTarget,
132
+ ): DocumentNavigationSnapshot;
133
+
134
+ // ---- fragment mapping -------------------------------------------------
135
+ getFragmentMapper(input: LayoutEngineQueryInput): PageFragmentMapper;
136
+
137
+ // ---- invalidation -----------------------------------------------------
138
+ invalidate(reason: LayoutInvalidationReason): void;
139
+ analyzeInvalidation(reason: LayoutInvalidationReason): InvalidationResult;
140
+ getDirtyFieldFamilies(): readonly string[];
141
+ clearDirtyFieldFamilies(families?: readonly string[]): void;
142
+
143
+ // ---- events -----------------------------------------------------------
144
+ subscribe(listener: (event: LayoutEngineEvent) => void): () => void;
145
+
146
+ // ---- measurement plumbing --------------------------------------------
147
+ swapMeasurementProvider(provider: LayoutMeasurementProvider): void;
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Cache key
152
+ // ---------------------------------------------------------------------------
153
+
154
+ interface CacheKey {
155
+ content: CanonicalDocumentEnvelope["content"];
156
+ styles: CanonicalDocumentEnvelope["styles"];
157
+ subParts: CanonicalDocumentEnvelope["subParts"];
158
+ // Note: view state does not invalidate the graph itself (graph is global).
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Perf-probe helper (§6 E.7)
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Observation-only counter: every full rebuild bumps
167
+ * `layout.full.by_reason.<reason.kind>` on `window.__DOCX_REACT_PERF_PROBE__`
168
+ * so the CCEP typing flow can be instrumented without changing invalidation
169
+ * routing. Safe in Node environments (window guard), and no-op when the perf
170
+ * probe has not been enabled (`window.__DOCX_REACT_PERF_PROBE__.enabled`).
171
+ */
172
+ function recordFullRebuildReason(reasonKind: string): void {
173
+ if (typeof window === "undefined") return;
174
+ const probe = (window as unknown as {
175
+ __DOCX_REACT_PERF_PROBE__?: {
176
+ enabled?: boolean;
177
+ invalidationCounts?: Record<string, number>;
178
+ };
179
+ }).__DOCX_REACT_PERF_PROBE__;
180
+ if (!probe?.enabled) return;
181
+ probe.invalidationCounts ??= {};
182
+ const totalKey = "layout.full.total";
183
+ const byReasonKey = `layout.full.by_reason.${reasonKind}`;
184
+ probe.invalidationCounts[totalKey] = (probe.invalidationCounts[totalKey] ?? 0) + 1;
185
+ probe.invalidationCounts[byReasonKey] =
186
+ (probe.invalidationCounts[byReasonKey] ?? 0) + 1;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Factory
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export interface CreateLayoutEngineOptions {
194
+ /** Optional measurement provider. Defaults to empirical. */
195
+ measurementProvider?: LayoutMeasurementProvider;
196
+ }
197
+
198
+ export function createLayoutEngine(
199
+ options: CreateLayoutEngineOptions = {},
200
+ ): LayoutEngineInstance {
201
+ let measurementProvider: LayoutMeasurementProvider =
202
+ options.measurementProvider ?? createEmpiricalMeasurementProvider();
203
+ const dirtyFieldFamilies = new Set<string>();
204
+ const listeners = new Set<(event: LayoutEngineEvent) => void>();
205
+ let cachedKey: CacheKey | null = null;
206
+ let cachedGraph: RuntimePageGraph | null = null;
207
+ let cachedFormatting: ResolvedFormattingState | null = null;
208
+ let cachedMapper: PageFragmentMapper | null = null;
209
+ let previousPageCount = 0;
210
+ /**
211
+ * Invalidation reason stashed by `invalidate()` and consumed on the next
212
+ * `getGraphInternal()` call. For bounded scopes this is what lets the
213
+ * engine splice instead of rebuild.
214
+ */
215
+ let pendingInvalidation: {
216
+ reason: LayoutInvalidationReason;
217
+ result: InvalidationResult;
218
+ } | null = null;
219
+
220
+ function emit(event: LayoutEngineEvent): void {
221
+ for (const listener of listeners) {
222
+ try {
223
+ listener(event);
224
+ } catch {
225
+ // never let a listener error interrupt the engine
226
+ }
227
+ }
228
+ }
229
+
230
+ function fullRebuild(
231
+ input: LayoutEngineQueryInput,
232
+ reason?: LayoutInvalidationReason,
233
+ ): RuntimePageGraph {
234
+ // §6 E.7 — observation-only perf probe. Attribute every full rebuild
235
+ // back to the invalidation reason that triggered it so we can later
236
+ // narrow specific kinds (numbering/section) without guessing.
237
+ recordFullRebuildReason(reason?.kind ?? "unknown");
238
+ const document = input.document;
239
+ const mainSurface = createEditorSurfaceSnapshot(
240
+ document,
241
+ createSelectionSnapshot(0, 0),
242
+ MAIN_STORY_TARGET,
243
+ );
244
+ const sections = buildResolvedSections(document);
245
+ const pages = buildPageStack(document, sections, mainSurface);
246
+ const stories = resolvePageStories(pages);
247
+ const graph = buildPageGraph({ pages, sections, stories });
248
+
249
+ // Field dirtiness diff from previous graph
250
+ const dirtyFamilies = computeFieldDirtiness(cachedGraph, graph);
251
+ for (const family of dirtyFamilies) {
252
+ dirtyFieldFamilies.add(family);
253
+ }
254
+
255
+ const formatting = buildResolvedFormattingState(document, mainSurface);
256
+
257
+ const currentPageCount = resolveTotalPageCount(pages);
258
+ if (currentPageCount !== previousPageCount) {
259
+ emit({
260
+ kind: "page_count_changed",
261
+ revision: graph.revision,
262
+ previousPageCount,
263
+ currentPageCount,
264
+ });
265
+ previousPageCount = currentPageCount;
266
+ }
267
+
268
+ if (dirtyFamilies.length > 0) {
269
+ emit({
270
+ kind: "page_field_dirtied",
271
+ revision: graph.revision,
272
+ dirtyFieldFamilies: dirtyFamilies,
273
+ });
274
+ }
275
+
276
+ emit({
277
+ kind: "layout_recomputed",
278
+ revision: graph.revision,
279
+ ...(reason ? { reason } : {}),
280
+ });
281
+
282
+ cachedKey = {
283
+ content: document.content,
284
+ styles: document.styles,
285
+ subParts: document.subParts,
286
+ };
287
+ cachedGraph = graph;
288
+ cachedFormatting = formatting;
289
+ cachedMapper = createPageFragmentMapper(graph);
290
+ return graph;
291
+ }
292
+
293
+ function incrementalRelayout(
294
+ input: LayoutEngineQueryInput,
295
+ pending: { reason: LayoutInvalidationReason; result: InvalidationResult },
296
+ ): RuntimePageGraph | null {
297
+ const priorGraph = cachedGraph;
298
+ const range = pending.result.dirtyPageRange;
299
+ if (!priorGraph || !range) return null;
300
+ const firstDirty = range.firstPageIndex;
301
+ if (firstDirty < 0 || firstDirty >= priorGraph.pages.length) return null;
302
+
303
+ const document = input.document;
304
+ const mainSurface = createEditorSurfaceSnapshot(
305
+ document,
306
+ createSelectionSnapshot(0, 0),
307
+ MAIN_STORY_TARGET,
308
+ );
309
+ const sections = buildResolvedSections(document);
310
+
311
+ const dirtyPage = priorGraph.pages[firstDirty]!;
312
+ const freshSnapshots = buildPageStackFrom(document, sections, mainSurface, {
313
+ startPageIndex: firstDirty,
314
+ startOffset: dirtyPage.startOffset,
315
+ });
316
+
317
+ // Convert fresh DocumentPageSnapshots into RuntimePageNodes via the
318
+ // standard buildPageGraph pipeline — this keeps region, story, and
319
+ // fragment synthesis consistent with the full-rebuild path. If the
320
+ // splice would not preserve the previously-held page count contract
321
+ // (e.g. the tail is empty because the edit deleted everything past
322
+ // firstDirty) we fall back to a full rebuild to stay safe.
323
+ if (freshSnapshots.length === 0 && firstDirty > 0) {
324
+ return null;
325
+ }
326
+ const freshStories = resolvePageStories(freshSnapshots);
327
+ const freshGraph = buildPageGraph({
328
+ pages: freshSnapshots,
329
+ sections,
330
+ stories: freshStories,
331
+ });
332
+ const freshNodes = freshGraph.pages;
333
+
334
+ const splicedGraph = spliceGraph(priorGraph, freshNodes, firstDirty);
335
+
336
+ // Field dirtiness diff and resolved-formatting update run against the
337
+ // full spliced graph so NUMPAGES/PAGE tracking remains accurate.
338
+ const dirtyFamilies = computeFieldDirtiness(priorGraph, splicedGraph);
339
+ for (const family of dirtyFamilies) {
340
+ dirtyFieldFamilies.add(family);
341
+ }
342
+ const formatting = buildResolvedFormattingState(document, mainSurface);
343
+
344
+ const currentPageCount = resolveTotalPageCount(
345
+ deriveDocumentPageSnapshots(splicedGraph),
346
+ );
347
+ if (currentPageCount !== previousPageCount) {
348
+ emit({
349
+ kind: "page_count_changed",
350
+ revision: splicedGraph.revision,
351
+ previousPageCount,
352
+ currentPageCount,
353
+ });
354
+ previousPageCount = currentPageCount;
355
+ }
356
+
357
+ if (dirtyFamilies.length > 0) {
358
+ emit({
359
+ kind: "page_field_dirtied",
360
+ revision: splicedGraph.revision,
361
+ dirtyFieldFamilies: dirtyFamilies,
362
+ });
363
+ }
364
+
365
+ emit({
366
+ kind: "incremental_relayout",
367
+ revision: splicedGraph.revision,
368
+ reason: pending.reason,
369
+ firstDirtyPageIndex: firstDirty,
370
+ });
371
+
372
+ cachedKey = {
373
+ content: document.content,
374
+ styles: document.styles,
375
+ subParts: document.subParts,
376
+ };
377
+ cachedGraph = splicedGraph;
378
+ cachedFormatting = formatting;
379
+ cachedMapper = rebuildMapper(
380
+ cachedMapper ?? createPageFragmentMapper(splicedGraph),
381
+ splicedGraph,
382
+ firstDirty,
383
+ );
384
+ return splicedGraph;
385
+ }
386
+
387
+ function getGraphInternal(input: LayoutEngineQueryInput): RuntimePageGraph {
388
+ const document = input.document;
389
+ const keyEqual =
390
+ cachedGraph !== null &&
391
+ cachedKey !== null &&
392
+ cachedKey.content === document.content &&
393
+ cachedKey.styles === document.styles &&
394
+ cachedKey.subParts === document.subParts;
395
+
396
+ if (keyEqual && pendingInvalidation === null) {
397
+ return cachedGraph!;
398
+ }
399
+
400
+ const pending = pendingInvalidation;
401
+ pendingInvalidation = null;
402
+
403
+ if (
404
+ pending !== null &&
405
+ pending.result.scope === "bounded" &&
406
+ cachedGraph !== null
407
+ ) {
408
+ const spliced = incrementalRelayout(input, pending);
409
+ if (spliced !== null) {
410
+ return spliced;
411
+ }
412
+ // Incremental path declined to produce a graph — fall through to full
413
+ // rebuild using the pending reason so listeners still see the cause.
414
+ return fullRebuild(input, pending.reason);
415
+ }
416
+
417
+ return fullRebuild(input, pending?.reason);
418
+ }
419
+
420
+ function getMapper(input: LayoutEngineQueryInput): PageFragmentMapper {
421
+ getGraphInternal(input);
422
+ return cachedMapper!;
423
+ }
424
+
425
+ function getFormatting(input: LayoutEngineQueryInput): ResolvedFormattingState {
426
+ getGraphInternal(input);
427
+ return cachedFormatting!;
428
+ }
429
+
430
+ return {
431
+ get measurementFidelity() {
432
+ return measurementProvider.fidelity;
433
+ },
434
+
435
+ whenMeasurementReady() {
436
+ return measurementProvider.whenReady();
437
+ },
438
+
439
+ getPageGraph(input) {
440
+ return getGraphInternal(input);
441
+ },
442
+
443
+ getResolvedFormattingState(input) {
444
+ return getFormatting(input);
445
+ },
446
+
447
+ getPageStack(input) {
448
+ return deriveDocumentPageSnapshots(getGraphInternal(input));
449
+ },
450
+
451
+ getPageLayoutSnapshot(input, sectionIndex) {
452
+ return derivePageLayoutSnapshotFromGraph(getGraphInternal(input), sectionIndex);
453
+ },
454
+
455
+ getNavigationSnapshot(input, selection, activeStory) {
456
+ const graph = getGraphInternal(input);
457
+ const selectionHead = selection.head;
458
+ const nav = buildNavigationFromGraph(
459
+ graph,
460
+ input.document,
461
+ activeStory,
462
+ selectionHead,
463
+ );
464
+ return nav;
465
+ },
466
+
467
+ getFragmentMapper(input) {
468
+ return getMapper(input);
469
+ },
470
+
471
+ invalidate(reason) {
472
+ const result = analyzeInvalidation(reason, cachedGraph);
473
+ for (const family of result.dirtyFieldFamilies) {
474
+ dirtyFieldFamilies.add(family);
475
+ }
476
+
477
+ if (result.scope === "bounded") {
478
+ // Retain the cache — the next getGraphInternal() call will splice
479
+ // fresh pages into the preserved prefix. Only record the pending
480
+ // reason so the splice path can run.
481
+ pendingInvalidation = { reason, result };
482
+ } else {
483
+ // Full rebuild or field-only refresh: drop the cached graph as
484
+ // before. Field families were added above so downstream consumers
485
+ // still observe a refresh signal for field-only reasons.
486
+ cachedKey = null;
487
+ cachedGraph = null;
488
+ cachedFormatting = null;
489
+ cachedMapper = null;
490
+ pendingInvalidation = { reason, result };
491
+ }
492
+ // No event emitted here. fullRebuild() and incrementalRelayout() are
493
+ // the sole emitters of relayout events; they fire once each on the
494
+ // next getGraphInternal() call with the correct post-recompute
495
+ // revision.
496
+ },
497
+
498
+ analyzeInvalidation(reason) {
499
+ return analyzeInvalidation(reason, cachedGraph);
500
+ },
501
+
502
+ getDirtyFieldFamilies() {
503
+ return Array.from(dirtyFieldFamilies);
504
+ },
505
+
506
+ clearDirtyFieldFamilies(families) {
507
+ if (!families) {
508
+ dirtyFieldFamilies.clear();
509
+ return;
510
+ }
511
+ for (const family of families) {
512
+ dirtyFieldFamilies.delete(family);
513
+ }
514
+ },
515
+
516
+ subscribe(listener) {
517
+ listeners.add(listener);
518
+ return () => {
519
+ listeners.delete(listener);
520
+ };
521
+ },
522
+
523
+ swapMeasurementProvider(provider) {
524
+ measurementProvider = provider;
525
+ emit({
526
+ kind: "measurement_backend_ready",
527
+ revision: cachedGraph?.revision ?? 0,
528
+ fidelity: provider.fidelity,
529
+ });
530
+ },
531
+ };
532
+ }
533
+
534
+ // ---------------------------------------------------------------------------
535
+ // Navigation derivation from graph
536
+ // ---------------------------------------------------------------------------
537
+
538
+ function buildNavigationFromGraph(
539
+ graph: RuntimePageGraph,
540
+ document: CanonicalDocumentEnvelope,
541
+ activeStory: EditorStoryTarget,
542
+ selectionHead: number,
543
+ ): DocumentNavigationSnapshot {
544
+ const pages = deriveDocumentPageSnapshots(graph);
545
+ const sections = graph.sections;
546
+
547
+ if (activeStory.kind === "main") {
548
+ const activePageIndex = deriveActivePageIndex(graph, selectionHead);
549
+ return {
550
+ pageCount: pages.length,
551
+ pages,
552
+ headings: buildHeadings(graph),
553
+ activePageIndex,
554
+ activeSectionIndex: deriveActiveSectionIndex(graph, selectionHead),
555
+ };
556
+ }
557
+
558
+ if (activeStory.kind === "header" || activeStory.kind === "footer") {
559
+ const sectionIndex =
560
+ "sectionIndex" in activeStory && typeof activeStory.sectionIndex === "number"
561
+ ? activeStory.sectionIndex
562
+ : 0;
563
+ const firstPage = graph.pages.findIndex((p) => p.sectionIndex === sectionIndex);
564
+ return {
565
+ pageCount: pages.length,
566
+ pages,
567
+ headings: buildHeadings(graph),
568
+ activePageIndex: firstPage >= 0 ? firstPage : 0,
569
+ activeSectionIndex: sectionIndex,
570
+ };
571
+ }
572
+
573
+ if (activeStory.kind === "footnote" || activeStory.kind === "endnote") {
574
+ const mainSurface = createEditorSurfaceSnapshot(
575
+ document,
576
+ createSelectionSnapshot(0, 0),
577
+ MAIN_STORY_TARGET,
578
+ );
579
+ const referencePosition = findNoteReferencePosition(mainSurface, activeStory);
580
+ const activePageIndex = deriveActivePageIndex(graph, referencePosition);
581
+ return {
582
+ pageCount: pages.length,
583
+ pages,
584
+ headings: buildHeadings(graph),
585
+ activePageIndex,
586
+ activeSectionIndex:
587
+ graph.pages[activePageIndex]?.sectionIndex ??
588
+ findSectionForPosition(sections, referencePosition).index,
589
+ };
590
+ }
591
+
592
+ return {
593
+ pageCount: pages.length,
594
+ pages,
595
+ headings: buildHeadings(graph),
596
+ activePageIndex: 0,
597
+ activeSectionIndex: 0,
598
+ };
599
+ }
600
+
601
+ function buildHeadings(graph: RuntimePageGraph) {
602
+ // Headings are derived elsewhere (createDocumentNavigationSnapshot pipes
603
+ // them from its own helper). For engine-native reads we return an empty
604
+ // list; the existing navigation snapshot keeps its headings pipeline.
605
+ void graph;
606
+ return [];
607
+ }
608
+
609
+ // ---------------------------------------------------------------------------
610
+ // Convenience: find the active page node directly
611
+ // ---------------------------------------------------------------------------
612
+
613
+ export function findActivePageNode(
614
+ graph: RuntimePageGraph,
615
+ selectionHead: number,
616
+ ): RuntimePageNode | undefined {
617
+ return deriveActivePage(graph, selectionHead) ?? findPageNodeForOffset(graph, selectionHead);
618
+ }