@beyondwork/docx-react-component 1.0.36 → 1.0.38

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