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