@beyondwork/docx-react-component 1.0.37 → 1.0.39

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 (116) hide show
  1. package/package.json +41 -31
  2. package/src/api/public-types.ts +496 -1
  3. package/src/core/commands/section-layout-commands.ts +58 -0
  4. package/src/core/commands/table-grid.ts +431 -0
  5. package/src/core/commands/table-structure-commands.ts +845 -56
  6. package/src/core/commands/text-commands.ts +122 -2
  7. package/src/io/docx-session.ts +1 -0
  8. package/src/io/export/serialize-main-document.ts +2 -11
  9. package/src/io/export/serialize-numbering.ts +43 -10
  10. package/src/io/export/serialize-paragraph-formatting.ts +152 -0
  11. package/src/io/export/serialize-run-formatting.ts +90 -0
  12. package/src/io/export/serialize-styles.ts +212 -0
  13. package/src/io/export/serialize-tables.ts +74 -0
  14. package/src/io/export/table-properties-xml.ts +139 -4
  15. package/src/io/normalize/normalize-text.ts +15 -0
  16. package/src/io/ooxml/parse-fields.ts +10 -3
  17. package/src/io/ooxml/parse-footnotes.ts +60 -0
  18. package/src/io/ooxml/parse-headers-footers.ts +60 -0
  19. package/src/io/ooxml/parse-main-document.ts +137 -0
  20. package/src/io/ooxml/parse-numbering.ts +41 -1
  21. package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
  22. package/src/io/ooxml/parse-run-formatting.ts +129 -0
  23. package/src/io/ooxml/parse-styles.ts +31 -0
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/io/ooxml/xml-attr-helpers.ts +60 -0
  26. package/src/io/ooxml/xml-element.ts +19 -0
  27. package/src/model/canonical-document.ts +117 -3
  28. package/src/runtime/collab/event-types.ts +165 -0
  29. package/src/runtime/collab/index.ts +22 -0
  30. package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
  31. package/src/runtime/collab/runtime-collab-sync.ts +273 -0
  32. package/src/runtime/document-layout.ts +4 -2
  33. package/src/runtime/document-navigation.ts +1 -1
  34. package/src/runtime/document-runtime.ts +248 -18
  35. package/src/runtime/layout/default-page-format.ts +96 -0
  36. package/src/runtime/layout/index.ts +47 -0
  37. package/src/runtime/layout/inert-layout-facet.ts +16 -0
  38. package/src/runtime/layout/layout-engine-instance.ts +100 -23
  39. package/src/runtime/layout/layout-invalidation.ts +14 -5
  40. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-graph.ts +55 -0
  43. package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
  44. package/src/runtime/layout/paginated-layout-engine.ts +484 -37
  45. package/src/runtime/layout/project-block-fragments.ts +225 -0
  46. package/src/runtime/layout/public-facet.ts +748 -16
  47. package/src/runtime/layout/resolve-page-fields.ts +70 -0
  48. package/src/runtime/layout/resolve-page-previews.ts +185 -0
  49. package/src/runtime/layout/resolved-formatting-state.ts +30 -26
  50. package/src/runtime/layout/table-render-plan.ts +249 -0
  51. package/src/runtime/numbering-prefix.ts +5 -0
  52. package/src/runtime/paragraph-style-resolver.ts +194 -0
  53. package/src/runtime/render/block-fragment-projection.ts +35 -0
  54. package/src/runtime/render/decoration-resolver.ts +189 -0
  55. package/src/runtime/render/index.ts +57 -0
  56. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  57. package/src/runtime/render/render-frame-types.ts +317 -0
  58. package/src/runtime/render/render-kernel.ts +759 -0
  59. package/src/runtime/resolved-numbering-geometry.ts +9 -1
  60. package/src/runtime/surface-projection.ts +129 -9
  61. package/src/runtime/table-schema.ts +11 -0
  62. package/src/runtime/view-state.ts +67 -0
  63. package/src/runtime/workflow-markup.ts +1 -5
  64. package/src/runtime/workflow-rail-segments.ts +280 -0
  65. package/src/ui/WordReviewEditor.tsx +368 -19
  66. package/src/ui/editor-command-bag.ts +4 -0
  67. package/src/ui/editor-runtime-boundary.ts +16 -0
  68. package/src/ui/editor-shell-view.tsx +10 -0
  69. package/src/ui/editor-surface-controller.tsx +9 -1
  70. package/src/ui/headless/chrome-registry.ts +310 -15
  71. package/src/ui/headless/scoped-chrome-policy.ts +49 -1
  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/review-queue-bar.tsx +2 -14
  75. package/src/ui-tailwind/chrome/role-action-sets.ts +80 -0
  76. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  77. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +160 -0
  78. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +68 -92
  79. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  80. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
  81. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  82. package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
  83. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +356 -140
  84. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
  85. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +94 -0
  86. package/src/ui-tailwind/chrome-overlay/index.ts +16 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +96 -0
  88. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  89. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +4 -0
  90. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +11 -0
  91. package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
  92. package/src/ui-tailwind/editor-surface/perf-probe.ts +7 -1
  93. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +40 -4
  94. package/src/ui-tailwind/editor-surface/pm-decorations.ts +22 -12
  95. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
  96. package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
  97. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
  98. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
  99. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
  100. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -75
  101. package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
  102. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
  103. package/src/ui-tailwind/index.ts +29 -0
  104. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  105. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  106. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  107. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  108. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  109. package/src/ui-tailwind/theme/editor-theme.css +498 -163
  110. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +680 -0
  111. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  112. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  113. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +104 -2
  114. package/src/ui-tailwind/tw-review-workspace.tsx +234 -21
  115. package/src/runtime/collab-review-sync.ts +0 -254
  116. package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
@@ -42,7 +42,42 @@ import type {
42
42
  LayoutEngineInstance,
43
43
  LayoutEngineQueryInput,
44
44
  } from "./layout-engine-instance.ts";
45
+ import type { LayoutMeasurementProvider } from "./layout-measurement-provider.ts";
45
46
  import type { PageFragmentMapper } from "./page-fragment-mapper.ts";
47
+ import {
48
+ PAGE_FORMAT_CATALOG,
49
+ matchPageFormat,
50
+ type PageFormatDefinition,
51
+ type ActivePageFormat,
52
+ } from "./page-format-catalog.ts";
53
+ import {
54
+ MARGIN_PRESET_CATALOG,
55
+ matchMarginPreset,
56
+ type MarginPresetDefinition,
57
+ type ActiveMarginPreset,
58
+ } from "./margin-preset-catalog.ts";
59
+ import {
60
+ collectScopeRailSegments,
61
+ type ScopeRailSegment,
62
+ } from "../workflow-rail-segments.ts";
63
+ import { createEditorSurfaceSnapshot } from "../surface-projection.ts";
64
+ import { MAIN_STORY_TARGET } from "../../core/selection/mapping.ts";
65
+ import { createSelectionSnapshot } from "../../core/state/editor-state.ts";
66
+ import { resolveTableStyleResolution } from "../table-style-resolver.ts";
67
+ import { buildTableRenderPlan } from "./table-render-plan.ts";
68
+ import { recordPerfSample } from "../../ui-tailwind/editor-surface/perf-probe.ts";
69
+ import type {
70
+ SurfaceBlockSnapshot,
71
+ } from "../../api/public-types";
72
+
73
+ export type {
74
+ PageFormatDefinition,
75
+ ActivePageFormat,
76
+ MarginPresetDefinition,
77
+ ActiveMarginPreset,
78
+ };
79
+
80
+ export type { ScopeRailSegment };
46
81
 
47
82
  // ---------------------------------------------------------------------------
48
83
  // Public read model types (shape-stable, cloned at the facet boundary)
@@ -224,8 +259,35 @@ export type LayoutFacetEvent =
224
259
  kind: "measurement_backend_ready";
225
260
  fidelity: PublicMeasurementFidelity;
226
261
  revision: number;
262
+ }
263
+ | {
264
+ kind: "incremental_relayout";
265
+ revision: number;
266
+ pageRange: { fromPageIndex: number; toPageIndex: number };
267
+ reason?: LayoutFacetInvalidationReason;
268
+ }
269
+ | {
270
+ kind: "render_frame_ready";
271
+ revision: number;
272
+ pageRange?: { fromPageIndex: number; toPageIndex: number };
273
+ }
274
+ | {
275
+ kind: "zoom_changed";
276
+ revision: number;
277
+ zoom: RenderZoomSummary;
227
278
  };
228
279
 
280
+ /**
281
+ * Minimal zoom summary carried with `zoom_changed`. The render kernel
282
+ * (when shipped) provides richer zoom metadata; consumers today only need
283
+ * the resolved px-per-twip plus the fit mode to reposition chrome.
284
+ */
285
+ export interface RenderZoomSummary {
286
+ pxPerTwip: number;
287
+ viewportWidthPx: number;
288
+ fitMode: "fixed" | "page-width" | "one-page";
289
+ }
290
+
229
291
  export type LayoutFacetInvalidationReason =
230
292
  | { kind: "content-edit"; from: number; to: number }
231
293
  | { kind: "section-change"; sectionIndex: number }
@@ -267,16 +329,111 @@ export interface WordReviewEditorLayoutFacet {
267
329
  getDisplayPageNumber(pageIndex: number): number | null;
268
330
  getLineBoxes(
269
331
  pageIndex: number,
270
- options?: { region?: "body" | "header" | "footer" },
332
+ options?: { region?: PublicPageRegion["kind"]; columnIndex?: number },
271
333
  ): PublicLineBox[];
334
+ /** Back-compat alias; prefer `getLineBoxes(pageIndex, { region })`. */
335
+ getLineBoxesForRegion(
336
+ pageIndex: number,
337
+ region: PublicPageRegion["kind"],
338
+ options?: { columnIndex?: number },
339
+ ): readonly PublicLineBox[];
272
340
  getFragmentsForPage(pageIndex: number): PublicBlockFragment[];
273
341
 
342
+ // Page-format catalog --------------------------------------------------
343
+ getPageFormatCatalog(): readonly PageFormatDefinition[];
344
+ getActivePageFormat(sectionIndex: number): ActivePageFormat | null;
345
+ getMarginPresetCatalog(): readonly MarginPresetDefinition[];
346
+ getActiveMarginPreset(sectionIndex: number): ActiveMarginPreset | null;
347
+
348
+ // Render-frame access (R1) --------------------------------------------
349
+ /**
350
+ * Return the active `RenderFrame` produced by the runtime-owned render
351
+ * kernel. Optional when the host runtime has not yet installed a kernel
352
+ * — returns `null` in that case so consumers can fall back to the page-
353
+ * graph reads above.
354
+ */
355
+ getRenderFrame?(
356
+ options?: import("../render/index.ts").RenderFrameQueryOptions,
357
+ ): import("../render/index.ts").RenderFrame | null;
358
+
359
+ /** Return the render-kernel zoom, if a kernel is installed. */
360
+ getRenderZoom?(): import("../render/index.ts").RenderZoom | null;
361
+
362
+ /**
363
+ * Hit-test a point in the mounted shell's coordinate space against the
364
+ * current render frame. Returns the deepest matching region with runtime
365
+ * offset, block id, fragment id, and line index so chrome surfaces can
366
+ * turn mouse coordinates into canonical positions without consulting the
367
+ * DOM. Returns `null` when no kernel is installed, or when the point
368
+ * falls outside every page.
369
+ */
370
+ hitTest?(
371
+ pointInRoot: import("../render/index.ts").RenderPoint,
372
+ ): import("../render/index.ts").RenderHitResult | null;
373
+
374
+ /**
375
+ * Resolve anchor rects for a `RenderAnchorQuery`. Returns `[]` when the
376
+ * kernel is absent or the query does not match any anchor. Chrome
377
+ * surfaces that need a single rect can read `getAnchorRects(q)[0]`;
378
+ * selection-spanning surfaces read the full list and union as needed.
379
+ */
380
+ getAnchorRects?(
381
+ query: import("../render/index.ts").RenderAnchorQuery,
382
+ ): readonly import("../render/index.ts").RenderFrameRect[];
383
+
384
+ // Scope rail segments (R3a) -------------------------------------------
385
+ /**
386
+ * Return workflow rail segments active on a given page. Returns an empty
387
+ * list when the host runtime did not supply workflow data to the facet.
388
+ */
389
+ getScopeRailSegments(
390
+ pageIndex: number,
391
+ ): readonly import("../workflow-rail-segments.ts").ScopeRailSegment[];
392
+ /** Return every scope rail segment across the document. */
393
+ getAllScopeRailSegments(): readonly import("../workflow-rail-segments.ts").ScopeRailSegment[];
394
+
274
395
  // Measurement exposure -------------------------------------------------
275
396
  getResolvedFormatting(blockId: string): PublicResolvedParagraphFormatting | null;
276
397
  getResolvedRunFormatting(runId: string): PublicResolvedRunFormatting | null;
277
398
  getMeasurement(blockId: string): PublicBlockMeasurement | null;
278
399
  getMeasurementFidelity(): PublicMeasurementFidelity;
279
400
  whenMeasurementReady(): Promise<void>;
401
+ swapMeasurementProvider(provider: LayoutMeasurementProvider): void;
402
+
403
+ // Table render plan (P3e consumed by the render kernel, P4) ------------
404
+ /**
405
+ * 0-based page index where the first fragment of `blockId` lands, or
406
+ * `null` when the block has no fragments in the current page graph.
407
+ * Used by the tables facet to determine which page to request plans for
408
+ * without needing PM document offsets.
409
+ */
410
+ getFirstPageIndexForBlock(blockId: string): number | null;
411
+ /**
412
+ * Build a `TableRenderPlan` for a table block on a given page. Returns
413
+ * `null` when the blockId does not resolve to a table in the current
414
+ * surface. The plan carries columnsTwips, bandClasses, verticalMerges,
415
+ * repeatedHeaderRows, and columnResizeHandles so chrome can render
416
+ * band-aware cell styling and place column-resize grips without
417
+ * walking canonical state.
418
+ */
419
+ getTableRenderPlan(
420
+ blockId: string,
421
+ pageIndex: number,
422
+ ): import("./table-render-plan.ts").TableRenderPlan | null;
423
+
424
+ // Table render plan (P3e consumed by the render kernel, P4) ------------
425
+ /**
426
+ * Build a `TableRenderPlan` for a table block on a given page. Returns
427
+ * `null` when the blockId does not resolve to a table in the current
428
+ * surface. The plan carries columnsTwips, bandClasses, verticalMerges,
429
+ * repeatedHeaderRows, and columnResizeHandles so chrome can render
430
+ * band-aware cell styling and place column-resize grips without
431
+ * walking canonical state.
432
+ */
433
+ getTableRenderPlan(
434
+ blockId: string,
435
+ pageIndex: number,
436
+ ): import("./table-render-plan.ts").TableRenderPlan | null;
280
437
 
281
438
  // Fields ---------------------------------------------------------------
282
439
  getDirtyFieldFamilies(): readonly string[];
@@ -293,6 +450,27 @@ export interface WordReviewEditorLayoutFacet {
293
450
  export interface CreateLayoutFacetInput {
294
451
  engine: LayoutEngineInstance;
295
452
  getQueryInput: () => LayoutEngineQueryInput;
453
+ /**
454
+ * Optional render-kernel accessor. When supplied, the facet exposes
455
+ * `getRenderFrame` + `getRenderZoom` that delegate to the kernel.
456
+ */
457
+ renderKernel?: () =>
458
+ | import("../render/index.ts").RenderKernel
459
+ | null
460
+ | undefined;
461
+ /**
462
+ * Optional workflow-segments accessor. When supplied, the facet computes
463
+ * `getScopeRailSegments` / `getAllScopeRailSegments` by joining the host-
464
+ * supplied workflow state with the current page graph. When omitted,
465
+ * both methods return empty arrays.
466
+ */
467
+ getWorkflowRailInput?: () =>
468
+ | Omit<
469
+ import("../workflow-rail-segments.ts").CollectScopeRailSegmentsInput,
470
+ "pageGraph"
471
+ >
472
+ | null
473
+ | undefined;
296
474
  }
297
475
 
298
476
  export function createLayoutFacet(
@@ -312,7 +490,18 @@ export function createLayoutFacet(
312
490
 
313
491
  const listeners = new Set<(event: LayoutFacetEvent) => void>();
314
492
  const unsubscribeEngine = engine.subscribe((event: LayoutEngineEvent) => {
315
- const facetEvent = toFacetEvent(event);
493
+ // `incremental_relayout` needs the last page index so the facet can
494
+ // report a `pageRange: [fromPageIndex, toPageIndex]` without asking the
495
+ // engine for an extra read. Use a best-effort lookup; on zero-page
496
+ // graphs the range collapses to the same index.
497
+ let lastPageIndex = 0;
498
+ try {
499
+ const pageCount = engine.getPageGraph(getQueryInput()).pages.length;
500
+ lastPageIndex = Math.max(0, pageCount - 1);
501
+ } catch {
502
+ lastPageIndex = 0;
503
+ }
504
+ const facetEvent = toFacetEvent(event, lastPageIndex);
316
505
  if (!facetEvent) return;
317
506
  for (const listener of listeners) {
318
507
  try {
@@ -356,15 +545,10 @@ export function createLayoutFacet(
356
545
  .filter((node): node is PublicSectionNode => node !== null);
357
546
  },
358
547
 
359
- getPageForOffset(offset, _story) {
548
+ getPageForOffset(offset, story) {
360
549
  const graph = currentGraph();
361
- for (const page of graph.pages) {
362
- if (!page.isBlankFiller && offset < page.endOffset) {
363
- return toPublicPageNode(page, graph);
364
- }
365
- }
366
- const last = graph.pages[graph.pages.length - 1];
367
- return last ? toPublicPageNode(last, graph) : null;
550
+ const page = findPageForOffsetAndStory(graph, offset, story);
551
+ return page ? toPublicPageNode(page, graph) : null;
368
552
  },
369
553
 
370
554
  getPageSpanForSelection(selection) {
@@ -422,11 +606,109 @@ export function createLayoutFacet(
422
606
  const node = graph.pages[pageIndex];
423
607
  if (!node) return [];
424
608
  const region = options?.region ?? "body";
425
- // Today all line boxes live on the body. The region filter is
426
- // defensive for when header/footer metrics are also populated.
427
- return node.lineBoxes
428
- .filter(() => region === "body")
429
- .map((box) => toPublicLineBox(box));
609
+ return collectLineBoxesForRegion(
610
+ node,
611
+ region,
612
+ options?.columnIndex,
613
+ ).map((box) => toPublicLineBox(box));
614
+ },
615
+
616
+ getLineBoxesForRegion(pageIndex, region, options) {
617
+ const graph = currentGraph();
618
+ const node = graph.pages[pageIndex];
619
+ if (!node) return [];
620
+ return collectLineBoxesForRegion(node, region, options?.columnIndex).map(
621
+ (box) => toPublicLineBox(box),
622
+ );
623
+ },
624
+
625
+ getPageFormatCatalog() {
626
+ return PAGE_FORMAT_CATALOG;
627
+ },
628
+
629
+ getActivePageFormat(sectionIndex) {
630
+ const graph = currentGraph();
631
+ const sectionPages = graph.pages.filter(
632
+ (p) => p.sectionIndex === sectionIndex,
633
+ );
634
+ const firstPage = sectionPages[0];
635
+ if (!firstPage) return null;
636
+ return matchPageFormat({
637
+ sectionIndex,
638
+ widthTwips: firstPage.layout.pageWidth,
639
+ heightTwips: firstPage.layout.pageHeight,
640
+ });
641
+ },
642
+
643
+ getMarginPresetCatalog() {
644
+ return MARGIN_PRESET_CATALOG;
645
+ },
646
+
647
+ getActiveMarginPreset(sectionIndex) {
648
+ const graph = currentGraph();
649
+ const sectionPages = graph.pages.filter(
650
+ (p) => p.sectionIndex === sectionIndex,
651
+ );
652
+ const firstPage = sectionPages[0];
653
+ if (!firstPage) return null;
654
+ const layout = firstPage.layout;
655
+ // `mirrorMargins` is a document-level setting
656
+ // (`w:mirrorMargins`) that the current canonical model does not
657
+ // surface onto the per-section layout snapshot; pass `false` until
658
+ // that plumbing exists so the Mirrored preset only matches when
659
+ // callers pass the preset-builder helper explicitly.
660
+ return matchMarginPreset({
661
+ sectionIndex,
662
+ topTwips: layout.marginTop,
663
+ bottomTwips: layout.marginBottom,
664
+ leftTwips: layout.marginLeft,
665
+ rightTwips: layout.marginRight,
666
+ gutterTwips: layout.gutter,
667
+ mirrored: false,
668
+ });
669
+ },
670
+
671
+ getRenderFrame(options) {
672
+ const kernel = input.renderKernel?.();
673
+ if (!kernel) return null;
674
+ return kernel.getRenderFrame(options);
675
+ },
676
+
677
+ getRenderZoom() {
678
+ const kernel = input.renderKernel?.();
679
+ if (!kernel) return null;
680
+ return kernel.getZoom();
681
+ },
682
+
683
+ hitTest(pointInRoot) {
684
+ const kernel = input.renderKernel?.();
685
+ if (!kernel) return null;
686
+ const t0 = typeof performance !== "undefined" ? performance.now() : 0;
687
+ const frame = kernel.getRenderFrame();
688
+ const result = resolveHitTest(frame, pointInRoot);
689
+ if (t0 > 0) recordPerfSample("chrome.hit_test", performance.now() - t0);
690
+ return result;
691
+ },
692
+
693
+ getAnchorRects(query) {
694
+ const kernel = input.renderKernel?.();
695
+ if (!kernel) return [];
696
+ const frame = kernel.getRenderFrame();
697
+ return resolveAnchorRects(frame, query);
698
+ },
699
+
700
+ getScopeRailSegments(pageIndex) {
701
+ return collectScopeRailSegmentsForQuery(
702
+ input.getWorkflowRailInput?.(),
703
+ currentGraph(),
704
+ ).filter((segment) => segment.pageIndex === pageIndex);
705
+ },
706
+
707
+ getAllScopeRailSegments() {
708
+ return collectScopeRailSegmentsForQuery(
709
+ input.getWorkflowRailInput?.(),
710
+ currentGraph(),
711
+ );
430
712
  },
431
713
 
432
714
  getFragmentsForPage(pageIndex) {
@@ -481,6 +763,45 @@ export function createLayoutFacet(
481
763
  return engine.whenMeasurementReady();
482
764
  },
483
765
 
766
+ getFirstPageIndexForBlock(blockId) {
767
+ const graph = currentGraph();
768
+ const fragment = graph.fragments.find((f) => f.blockId === blockId);
769
+ if (!fragment) return null;
770
+ const page = graph.pages.find((p) => p.pageId === fragment.pageId);
771
+ return page?.pageIndex ?? null;
772
+ },
773
+
774
+ swapMeasurementProvider(provider) {
775
+ engine.swapMeasurementProvider(provider);
776
+ },
777
+
778
+ getTableRenderPlan(blockId, pageIndex) {
779
+ const graph = currentGraph();
780
+ const fragment = graph.fragments.find((f) => f.blockId === blockId);
781
+ if (!fragment) return null;
782
+ const queryInput = getQueryInput();
783
+ const surface = lazyMainSurfaceForFacet(queryInput);
784
+ const tableBlock = findTableBlockByBlockId(surface.blocks, blockId);
785
+ if (!tableBlock) return null;
786
+ const resolved = resolveTableStyleResolutionForPlan(
787
+ tableBlock,
788
+ queryInput.document,
789
+ );
790
+ if (!resolved) return null;
791
+ // Sum all fragments for this block so the grip height spans every
792
+ // page the table occupies.
793
+ const tableHeightTwips = graph.fragments
794
+ .filter((f) => f.blockId === blockId)
795
+ .reduce((total, f) => total + f.heightTwips, 0);
796
+ return buildTableRenderPlan({
797
+ blockId,
798
+ pageIndex,
799
+ block: tableBlock,
800
+ resolved,
801
+ tableHeightTwips,
802
+ });
803
+ },
804
+
484
805
  getDirtyFieldFamilies() {
485
806
  return engine.getDirtyFieldFamilies();
486
807
  },
@@ -671,7 +992,10 @@ function toPublicRunFormatting(
671
992
  };
672
993
  }
673
994
 
674
- function toFacetEvent(event: LayoutEngineEvent): LayoutFacetEvent | null {
995
+ function toFacetEvent(
996
+ event: LayoutEngineEvent,
997
+ lastPageIndex: number,
998
+ ): LayoutFacetEvent | null {
675
999
  switch (event.kind) {
676
1000
  case "layout_recomputed":
677
1001
  return {
@@ -699,7 +1023,415 @@ function toFacetEvent(event: LayoutEngineEvent): LayoutFacetEvent | null {
699
1023
  (event.fidelity as PublicMeasurementFidelity | undefined) ?? "empirical",
700
1024
  revision: event.revision,
701
1025
  };
1026
+ case "incremental_relayout": {
1027
+ const fromPageIndex =
1028
+ event.firstDirtyPageIndex !== undefined
1029
+ ? Math.max(0, event.firstDirtyPageIndex)
1030
+ : 0;
1031
+ const toPageIndex = Math.max(fromPageIndex, lastPageIndex);
1032
+ return {
1033
+ kind: "incremental_relayout",
1034
+ revision: event.revision,
1035
+ pageRange: { fromPageIndex, toPageIndex },
1036
+ ...(event.reason ? { reason: event.reason } : {}),
1037
+ };
1038
+ }
702
1039
  default:
703
1040
  return null;
704
1041
  }
705
1042
  }
1043
+
1044
+ /**
1045
+ * Resolve the page a given offset + story should surface on.
1046
+ *
1047
+ * Contract (per runtime-rendering-and-chrome-phase.md §2.3):
1048
+ * - Main story: the first content page containing the offset (unchanged).
1049
+ * - Header/footer story: the first page in the relevant section that
1050
+ * renders a header/footer matching the target variant. When the
1051
+ * section-index hint is omitted, the first page rendering any
1052
+ * variant of this kind is returned.
1053
+ * - Footnote/endnote story: the first page that reserves a note
1054
+ * allocation for the given note (by note id); falls back to the
1055
+ * first content page so UI surfaces always have an anchor.
1056
+ */
1057
+ function findPageForOffsetAndStory(
1058
+ graph: RuntimePageGraph,
1059
+ offset: number,
1060
+ story?: EditorStoryTarget,
1061
+ ): RuntimePageNode | null {
1062
+ if (!story || story.kind === "main") {
1063
+ for (const page of graph.pages) {
1064
+ if (!page.isBlankFiller && offset < page.endOffset) {
1065
+ return page;
1066
+ }
1067
+ }
1068
+ const last = graph.pages[graph.pages.length - 1];
1069
+ return last ?? null;
1070
+ }
1071
+
1072
+ if (story.kind === "header" || story.kind === "footer") {
1073
+ const targetKind = story.kind;
1074
+ const targetVariant = story.variant;
1075
+ const targetRelationshipId = story.relationshipId;
1076
+ const sectionIndex = story.sectionIndex;
1077
+ const candidates = graph.pages.filter((page) => {
1078
+ if (page.isBlankFiller) return false;
1079
+ if (sectionIndex !== undefined && page.sectionIndex !== sectionIndex) {
1080
+ return false;
1081
+ }
1082
+ const side =
1083
+ targetKind === "header" ? page.stories.header : page.stories.footer;
1084
+ if (!side) return false;
1085
+ if (side.kind !== "header" && side.kind !== "footer") return false;
1086
+ if (targetVariant !== undefined && side.variant !== targetVariant) {
1087
+ return false;
1088
+ }
1089
+ if (
1090
+ targetRelationshipId !== undefined &&
1091
+ side.relationshipId !== targetRelationshipId
1092
+ ) {
1093
+ return false;
1094
+ }
1095
+ return true;
1096
+ });
1097
+ if (candidates[0]) return candidates[0];
1098
+ // Fallback: any page in the given section (or the first page overall)
1099
+ if (sectionIndex !== undefined) {
1100
+ const fallback = graph.pages.find(
1101
+ (p) => p.sectionIndex === sectionIndex && !p.isBlankFiller,
1102
+ );
1103
+ if (fallback) return fallback;
1104
+ }
1105
+ const first = graph.pages.find((p) => !p.isBlankFiller);
1106
+ return first ?? graph.pages[0] ?? null;
1107
+ }
1108
+
1109
+ if (story.kind === "footnote" || story.kind === "endnote") {
1110
+ const noteKind = story.kind;
1111
+ const noteId = story.noteId;
1112
+ if (noteId !== undefined) {
1113
+ const page = graph.pages.find((p) =>
1114
+ p.noteAllocations.some(
1115
+ (a) => a.noteKind === noteKind && a.noteId === noteId,
1116
+ ),
1117
+ );
1118
+ if (page) return page;
1119
+ }
1120
+ for (const page of graph.pages) {
1121
+ if (!page.isBlankFiller && offset < page.endOffset) {
1122
+ return page;
1123
+ }
1124
+ }
1125
+ const last = graph.pages[graph.pages.length - 1];
1126
+ return last ?? null;
1127
+ }
1128
+
1129
+ return null;
1130
+ }
1131
+
1132
+ /**
1133
+ * Select line boxes that belong to a given region on a page.
1134
+ *
1135
+ * Today the engine populates body line boxes only; header/footer/column/
1136
+ * footnote-area regions produce empty arrays until the render kernel lands
1137
+ * (Phase R1). The region filter is now exact: `body` returns only body
1138
+ * line boxes, everything else returns empty. Consumers should prefer
1139
+ * `getLineBoxes(pageIndex, { region: "body" })` — the other kinds are
1140
+ * scaffolded so UI can start reading through the facet without special-
1141
+ * casing region availability.
1142
+ */
1143
+ function collectLineBoxesForRegion(
1144
+ node: RuntimePageNode,
1145
+ region: PublicPageRegion["kind"],
1146
+ _columnIndex: number | undefined,
1147
+ ): readonly RuntimeLineBoxAlias[] {
1148
+ void _columnIndex;
1149
+ if (region === "body") {
1150
+ return node.lineBoxes;
1151
+ }
1152
+ return EMPTY_LINE_BOXES;
1153
+ }
1154
+
1155
+ // Use a shared alias so the region helper doesn't import the runtime
1156
+ // `RuntimeLineBox` type redundantly (`lineBoxes` is already strongly typed on
1157
+ // `RuntimePageNode`).
1158
+ type RuntimeLineBoxAlias = RuntimePageNode["lineBoxes"][number];
1159
+ const EMPTY_LINE_BOXES: readonly RuntimeLineBoxAlias[] = Object.freeze([]);
1160
+
1161
+ /**
1162
+ * Join the host-supplied workflow rail input with the current page graph.
1163
+ * Returns an empty array when no input is available so callers can always
1164
+ * iterate without null-checking.
1165
+ */
1166
+ function collectScopeRailSegmentsForQuery(
1167
+ input:
1168
+ | Omit<
1169
+ import("../workflow-rail-segments.ts").CollectScopeRailSegmentsInput,
1170
+ "pageGraph"
1171
+ >
1172
+ | null
1173
+ | undefined,
1174
+ graph: RuntimePageGraph,
1175
+ ): ScopeRailSegment[] {
1176
+ if (!input) return [];
1177
+ return collectScopeRailSegments({ ...input, pageGraph: graph });
1178
+ }
1179
+
1180
+ function resolveHitTest(
1181
+ frame: import("../render/index.ts").RenderFrame | null,
1182
+ point: import("../render/index.ts").RenderPoint,
1183
+ ): import("../render/index.ts").RenderHitResult | null {
1184
+ if (!frame) return null;
1185
+ for (const page of frame.pages) {
1186
+ if (!containsPoint(page.frame, point)) continue;
1187
+ const regionHit = hitTestRegion(page, point, "body");
1188
+ if (regionHit) return regionHit;
1189
+ const header = hitTestRegion(page, point, "header");
1190
+ if (header) return header;
1191
+ const footer = hitTestRegion(page, point, "footer");
1192
+ if (footer) return footer;
1193
+ }
1194
+ return null;
1195
+ }
1196
+
1197
+ function hitTestRegion(
1198
+ page: import("../render/index.ts").RenderPage,
1199
+ point: import("../render/index.ts").RenderPoint,
1200
+ kind: "body" | "header" | "footer",
1201
+ ): import("../render/index.ts").RenderHitResult | null {
1202
+ const region =
1203
+ kind === "body"
1204
+ ? page.regions.body
1205
+ : kind === "header"
1206
+ ? page.regions.header
1207
+ : page.regions.footer;
1208
+ if (!region) return null;
1209
+ if (!containsPoint(region.frame, point)) return null;
1210
+ for (const block of region.blocks) {
1211
+ if (!containsPoint(block.frame, point)) continue;
1212
+ let bestLineIndex = -1;
1213
+ let bestLineDistance = Number.POSITIVE_INFINITY;
1214
+ for (let i = 0; i < block.lines.length; i++) {
1215
+ const line = block.lines[i]!;
1216
+ const midY = line.frame.topPx + line.frame.heightPx / 2;
1217
+ const distance = Math.abs(midY - point.yPx);
1218
+ if (distance < bestLineDistance) {
1219
+ bestLineDistance = distance;
1220
+ bestLineIndex = i;
1221
+ }
1222
+ }
1223
+ const lineIndex = bestLineIndex >= 0 ? bestLineIndex : 0;
1224
+ const line = block.lines[lineIndex];
1225
+ const runtimeOffset = line?.anchors[0]?.runtimeOffset ?? block.fragment.from;
1226
+ return {
1227
+ pageIndex: page.page.pageIndex,
1228
+ regionKind: region.region.kind,
1229
+ blockId: block.fragment.blockId,
1230
+ fragmentId: block.fragment.fragmentId,
1231
+ lineIndex,
1232
+ runtimeOffset,
1233
+ };
1234
+ }
1235
+ // Point fell inside the region but between blocks — snap to the nearest block.
1236
+ let nearestBlock: import("../render/index.ts").RenderBlock | null = null;
1237
+ let nearestDistance = Number.POSITIVE_INFINITY;
1238
+ for (const block of region.blocks) {
1239
+ const distance = Math.min(
1240
+ Math.abs(point.yPx - block.frame.topPx),
1241
+ Math.abs(point.yPx - (block.frame.topPx + block.frame.heightPx)),
1242
+ );
1243
+ if (distance < nearestDistance) {
1244
+ nearestBlock = block;
1245
+ nearestDistance = distance;
1246
+ }
1247
+ }
1248
+ if (!nearestBlock) return null;
1249
+ return {
1250
+ pageIndex: page.page.pageIndex,
1251
+ regionKind: region.region.kind,
1252
+ blockId: nearestBlock.fragment.blockId,
1253
+ fragmentId: nearestBlock.fragment.fragmentId,
1254
+ lineIndex: 0,
1255
+ runtimeOffset: nearestBlock.fragment.from,
1256
+ };
1257
+ }
1258
+
1259
+ function containsPoint(
1260
+ rect: import("../render/index.ts").RenderFrameRect,
1261
+ point: import("../render/index.ts").RenderPoint,
1262
+ ): boolean {
1263
+ return (
1264
+ point.xPx >= rect.leftPx &&
1265
+ point.xPx <= rect.leftPx + rect.widthPx &&
1266
+ point.yPx >= rect.topPx &&
1267
+ point.yPx <= rect.topPx + rect.heightPx
1268
+ );
1269
+ }
1270
+
1271
+ function resolveAnchorRects(
1272
+ frame: import("../render/index.ts").RenderFrame | null,
1273
+ query: import("../render/index.ts").RenderAnchorQuery,
1274
+ ): readonly import("../render/index.ts").RenderFrameRect[] {
1275
+ if (!frame) return [];
1276
+ switch (query.kind) {
1277
+ case "runtime-offset": {
1278
+ const offset =
1279
+ typeof query.value === "number" ? query.value : Number(query.value);
1280
+ if (!Number.isFinite(offset)) return [];
1281
+ const rect = frame.anchorIndex.byRuntimeOffset(offset, query.story);
1282
+ return rect ? [rect] : [];
1283
+ }
1284
+ case "block-id": {
1285
+ const rect = frame.anchorIndex.byBlockId(String(query.value));
1286
+ return rect ? [rect] : [];
1287
+ }
1288
+ case "fragment-id": {
1289
+ const rect = frame.anchorIndex.byFragmentId(String(query.value));
1290
+ return rect ? [rect] : [];
1291
+ }
1292
+ case "page-index": {
1293
+ const pageIndex =
1294
+ typeof query.value === "number" ? query.value : Number(query.value);
1295
+ if (!Number.isFinite(pageIndex)) return [];
1296
+ const rect = frame.anchorIndex.byPageIndex(pageIndex);
1297
+ return rect ? [rect] : [];
1298
+ }
1299
+ case "scope-id": {
1300
+ const id = String(query.value);
1301
+ return frame.decorationIndex.workflow
1302
+ .filter((decoration) => decoration.refId === id)
1303
+ .map((decoration) => decoration.frame);
1304
+ }
1305
+ case "comment-id": {
1306
+ const id = String(query.value);
1307
+ return frame.decorationIndex.comments
1308
+ .filter((decoration) => decoration.refId === id)
1309
+ .map((decoration) => decoration.frame);
1310
+ }
1311
+ case "revision-id": {
1312
+ const id = String(query.value);
1313
+ return frame.decorationIndex.revisions
1314
+ .filter((decoration) => decoration.refId === id)
1315
+ .map((decoration) => decoration.frame);
1316
+ }
1317
+ default: {
1318
+ const exhaustive: never = query.kind;
1319
+ void exhaustive;
1320
+ return [];
1321
+ }
1322
+ }
1323
+ }
1324
+
1325
+ // ---------------------------------------------------------------------------
1326
+ // Table render plan helpers (P4)
1327
+ // ---------------------------------------------------------------------------
1328
+
1329
+ /**
1330
+ * Produce (or reuse) the main surface snapshot the facet needs for table
1331
+ * plan resolution. The engine already rebuilds a surface on every full
1332
+ * relayout, but that output is not cached externally — for a per-call
1333
+ * `getTableRenderPlan` we reconstruct the surface with a static zero
1334
+ * selection. This is a pure read and only runs on chrome requests, so
1335
+ * the cost is bounded.
1336
+ */
1337
+ function lazyMainSurfaceForFacet(
1338
+ input: LayoutEngineQueryInput,
1339
+ ): ReturnType<typeof createEditorSurfaceSnapshot> {
1340
+ return createEditorSurfaceSnapshot(
1341
+ input.document,
1342
+ createSelectionSnapshot(0, 0),
1343
+ MAIN_STORY_TARGET,
1344
+ );
1345
+ }
1346
+
1347
+ /**
1348
+ * Walk a flat surface block list (with recursion into sdt/table nested
1349
+ * children) to find a table block by its projected blockId.
1350
+ */
1351
+ function findTableBlockByBlockId(
1352
+ blocks: readonly SurfaceBlockSnapshot[],
1353
+ blockId: string,
1354
+ ): Extract<SurfaceBlockSnapshot, { kind: "table" }> | null {
1355
+ for (const block of blocks) {
1356
+ if (block.kind === "table") {
1357
+ if (block.blockId === blockId) return block;
1358
+ // Recurse into nested tables (cell contents may contain tables).
1359
+ for (const row of block.rows) {
1360
+ for (const cell of row.cells) {
1361
+ const nested = findTableBlockByBlockId(cell.content, blockId);
1362
+ if (nested) return nested;
1363
+ }
1364
+ }
1365
+ } else if (block.kind === "sdt_block") {
1366
+ const nested = findTableBlockByBlockId(block.children, blockId);
1367
+ if (nested) return nested;
1368
+ }
1369
+ }
1370
+ return null;
1371
+ }
1372
+
1373
+ /**
1374
+ * Resolve the table-style cascade (direct properties → style chain → band
1375
+ * conditional formatting) for a surface table block. Needs the canonical
1376
+ * document to look up the underlying `TableNode` by blockId so the full
1377
+ * resolver runs against canonical state, not the pre-projected surface.
1378
+ */
1379
+ function resolveTableStyleResolutionForPlan(
1380
+ surfaceTable: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
1381
+ document: LayoutEngineQueryInput["document"],
1382
+ ): ReturnType<typeof resolveTableStyleResolution> | null {
1383
+ const canonicalTable = findCanonicalTableByBlockId(
1384
+ document.content.children,
1385
+ surfaceTable.blockId,
1386
+ { counter: { value: 0 } },
1387
+ );
1388
+ if (!canonicalTable) return null;
1389
+ return resolveTableStyleResolution(
1390
+ canonicalTable,
1391
+ document.styles.tables ?? {},
1392
+ );
1393
+ }
1394
+
1395
+ /**
1396
+ * Walk canonical content depth-first incrementing the table counter to
1397
+ * locate the `TableNode` whose projected blockId would be `table-${N}`.
1398
+ * Mirrors the counter discipline in `src/runtime/surface-projection.ts`.
1399
+ */
1400
+ function findCanonicalTableByBlockId(
1401
+ children: import("../../model/canonical-document.ts").BlockNode[],
1402
+ targetBlockId: string,
1403
+ state: { counter: { value: number } },
1404
+ ): import("../../model/canonical-document.ts").TableNode | null {
1405
+ for (const child of children) {
1406
+ if (child.type === "table") {
1407
+ const index = state.counter.value;
1408
+ state.counter.value += 1;
1409
+ if (`table-${index}` === targetBlockId) return child;
1410
+ for (const row of child.rows) {
1411
+ for (const cell of row.cells) {
1412
+ const nested = findCanonicalTableByBlockId(
1413
+ cell.children,
1414
+ targetBlockId,
1415
+ state,
1416
+ );
1417
+ if (nested) return nested;
1418
+ }
1419
+ }
1420
+ } else if (child.type === "sdt") {
1421
+ const nested = findCanonicalTableByBlockId(
1422
+ child.children,
1423
+ targetBlockId,
1424
+ state,
1425
+ );
1426
+ if (nested) return nested;
1427
+ } else if (child.type === "custom_xml") {
1428
+ const nested = findCanonicalTableByBlockId(
1429
+ child.children,
1430
+ targetBlockId,
1431
+ state,
1432
+ );
1433
+ if (nested) return nested;
1434
+ }
1435
+ }
1436
+ return null;
1437
+ }