@beyondwork/docx-react-component 1.0.42 → 1.0.43

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 (51) hide show
  1. package/package.json +30 -41
  2. package/src/api/editor-state-types.ts +110 -0
  3. package/src/api/public-types.ts +194 -1
  4. package/src/core/commands/index.ts +33 -8
  5. package/src/core/search/search-text.ts +15 -2
  6. package/src/index.ts +13 -0
  7. package/src/io/docx-session.ts +672 -2
  8. package/src/io/load-scheduler.ts +230 -0
  9. package/src/io/normalize/normalize-text.ts +83 -0
  10. package/src/io/ooxml/workflow-payload-validator.ts +97 -1
  11. package/src/io/ooxml/workflow-payload.ts +172 -1
  12. package/src/runtime/collab-session.ts +1 -1
  13. package/src/runtime/document-runtime.ts +364 -36
  14. package/src/runtime/editor-state-channel.ts +544 -0
  15. package/src/runtime/editor-state-integration.ts +217 -0
  16. package/src/runtime/layout/index.ts +2 -0
  17. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  18. package/src/runtime/layout/layout-engine-instance.ts +17 -2
  19. package/src/runtime/layout/paginated-layout-engine.ts +211 -14
  20. package/src/runtime/layout/public-facet.ts +400 -1
  21. package/src/runtime/perf-counters.ts +28 -0
  22. package/src/runtime/render/render-frame-types.ts +17 -0
  23. package/src/runtime/render/render-kernel.ts +172 -29
  24. package/src/runtime/surface-projection.ts +10 -5
  25. package/src/runtime/workflow-markup.ts +71 -16
  26. package/src/ui/WordReviewEditor.tsx +67 -45
  27. package/src/ui/editor-command-bag.ts +14 -0
  28. package/src/ui/editor-runtime-boundary.ts +110 -11
  29. package/src/ui/editor-shell-view.tsx +10 -0
  30. package/src/ui/editor-surface-controller.tsx +5 -0
  31. package/src/ui/headless/selection-helpers.ts +10 -0
  32. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  33. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  34. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
  35. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  36. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  37. package/src/ui-tailwind/editor-surface/pm-schema.ts +152 -4
  38. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  39. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  40. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
  41. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  42. package/src/ui-tailwind/index.ts +5 -1
  43. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  44. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  45. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  46. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  47. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  48. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  49. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  50. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  51. package/src/ui-tailwind/tw-review-workspace.tsx +172 -94
@@ -62,8 +62,12 @@ import {
62
62
  type ScopeRailSegment,
63
63
  } from "../workflow-rail-segments.ts";
64
64
  import { createEditorSurfaceSnapshot } from "../surface-projection.ts";
65
+ import { storyTargetKey } from "../story-targeting.ts";
65
66
  import { MAIN_STORY_TARGET } from "../../core/selection/mapping.ts";
66
- import { createSelectionSnapshot } from "../../core/state/editor-state.ts";
67
+ import {
68
+ createSelectionSnapshot,
69
+ type CanonicalDocumentEnvelope,
70
+ } from "../../core/state/editor-state.ts";
67
71
  import { resolveTableStyleResolution } from "../table-style-resolver.ts";
68
72
  import { buildTableRenderPlan } from "./table-render-plan.ts";
69
73
  import { recordPerfSample } from "../../ui-tailwind/editor-surface/perf-probe.ts";
@@ -124,6 +128,13 @@ export interface PublicPageRegions {
124
128
  header?: PublicPageRegion;
125
129
  footer?: PublicPageRegion;
126
130
  columns?: readonly PublicPageRegion[];
131
+ /**
132
+ * P8.3 — Footnote regions reserved at the bottom of the page (above the
133
+ * footer band). Mirrors `RuntimePageRegions.footnotes`. Present only
134
+ * when at least one allocation on this page produced a fragment — see
135
+ * `noteAllocations` for per-note metadata. Additive / back-compat safe.
136
+ */
137
+ footnotes?: readonly PublicPageRegion[];
127
138
  }
128
139
 
129
140
  export interface PublicPageRegion {
@@ -134,6 +145,15 @@ export interface PublicPageRegion {
134
145
  fragmentCount: number;
135
146
  }
136
147
 
148
+ /**
149
+ * P8 — Region kind discriminator used by `PublicRegionBlock` and the
150
+ * `getStoryBlocksForRegion` API. Mirrors `PublicPageRegion["kind"]` plus
151
+ * `"endnote-area"`, which is only ever surfaced via
152
+ * `getDocumentEndnoteBlocks` (endnotes have document-end placement and
153
+ * never sit on a body page).
154
+ */
155
+ export type PublicRegionKind = PublicPageRegion["kind"] | "endnote-area";
156
+
137
157
  export interface PublicBlockFragment {
138
158
  fragmentId: string;
139
159
  blockId: string;
@@ -146,6 +166,29 @@ export interface PublicBlockFragment {
146
166
  orderInRegion: number;
147
167
  }
148
168
 
169
+ /**
170
+ * P8 — One block snapshot rendered into a region of a page. Returned by
171
+ * `WordReviewEditorLayoutFacet.getStoryBlocksForRegion` and
172
+ * `getDocumentEndnoteBlocks`.
173
+ *
174
+ * The `blockSnapshot` field is the same `SurfaceBlockSnapshot` consumed by
175
+ * the body block renderer (`tw-page-block-view`), so region renderers reuse
176
+ * body typography verbatim. For body fragments `runtimeFromOffset`/`To`
177
+ * mirror the underlying `RuntimeBlockFragment.from`/`to`. For header /
178
+ * footer / footnote-area blocks the offsets are local to the host story
179
+ * (header story, footer story, or note body).
180
+ */
181
+ export interface PublicRegionBlock {
182
+ blockId: string;
183
+ fragmentId: string;
184
+ pageIndex: number;
185
+ regionKind: PublicRegionKind;
186
+ runtimeFromOffset: number;
187
+ runtimeToOffset: number;
188
+ heightTwips: number;
189
+ blockSnapshot: SurfaceBlockSnapshot;
190
+ }
191
+
149
192
  export interface PublicLineBox {
150
193
  fragmentId: string;
151
194
  lineIndex: number;
@@ -376,6 +419,29 @@ export interface WordReviewEditorLayoutFacet {
376
419
  getStoryRegionsOnPage(pageIndex: number): readonly PublicPageRegion[];
377
420
  getFragmentsForPage(pageIndex: number): PublicBlockFragment[];
378
421
 
422
+ /**
423
+ * P8 — Returns per-region block snapshots for a page. Body resolves the
424
+ * page's body fragments to their `SurfaceBlockSnapshot`s; header / footer
425
+ * resolve via the page's active story target + `subParts.headers/footers`
426
+ * lookup; footnote-area resolves per-page `noteAllocations` against
427
+ * `subParts.footnoteCollection.footnotes`. Endnote-area is always empty
428
+ * per-page — document-end placement is handled by
429
+ * `getDocumentEndnoteBlocks`. Column falls through to body (multi-column
430
+ * splitting is a P10 follow-up). Cached per revision; busts on
431
+ * `graph.revision` change.
432
+ */
433
+ getStoryBlocksForRegion(
434
+ pageIndex: number,
435
+ region: PublicPageRegion["kind"],
436
+ options?: { columnIndex?: number },
437
+ ): readonly PublicRegionBlock[];
438
+
439
+ /**
440
+ * P8 — Returns endnote bodies in document order for document-end
441
+ * placement (per OOXML default `w:endnotePr/w:pos="docEnd"`).
442
+ */
443
+ getDocumentEndnoteBlocks(): readonly PublicRegionBlock[];
444
+
379
445
  // Page-format catalog --------------------------------------------------
380
446
  getPageFormatCatalog(): readonly PageFormatDefinition[];
381
447
  getActivePageFormat(sectionIndex: number): ActivePageFormat | null;
@@ -509,6 +575,13 @@ export interface WordReviewEditorLayoutFacet {
509
575
  export interface CreateLayoutFacetInput {
510
576
  engine: LayoutEngineInstance;
511
577
  getQueryInput: () => LayoutEngineQueryInput;
578
+ /**
579
+ * P8 — Canonical document accessor used by `getStoryBlocksForRegion` /
580
+ * `getDocumentEndnoteBlocks` to resolve header / footer / footnote /
581
+ * endnote stories in `subParts`. When omitted the facet falls back to
582
+ * the document available on the engine query input.
583
+ */
584
+ canonicalDocument?: () => CanonicalDocumentEnvelope;
512
585
  /**
513
586
  * Optional render-kernel accessor. When supplied, the facet exposes
514
587
  * `getRenderFrame` + `getRenderZoom` that delegate to the kernel.
@@ -593,6 +666,151 @@ export function createLayoutFacet(
593
666
  // Keep the handle alive; the facet instance lives as long as the runtime.
594
667
  void unsubscribeEngine;
595
668
 
669
+ // P8 — per-revision cache for getStoryBlocksForRegion. Identity is
670
+ // preserved within a single graph revision so consumers can `===`-compare
671
+ // results across band renders, and the cache flushes whenever the engine
672
+ // produces a fresh page graph.
673
+ let regionBlocksCache: {
674
+ revision: number;
675
+ byKey: Map<string, readonly PublicRegionBlock[]>;
676
+ } = { revision: -1, byKey: new Map() };
677
+ // P8.2.1 — secondary cache keyed by header/footer story-target identity.
678
+ // A 38-page doc with one default header projects the story once instead of
679
+ // 38 times; per-page `PublicRegionBlock[]`s still differ (each carries the
680
+ // page's `pageIndex`), but the underlying `SurfaceBlockSnapshot[]` and
681
+ // every `blockSnapshot` reference are shared. Busts together with
682
+ // `regionBlocksCache` on `graph.revision` change.
683
+ let storyProjectionCache: {
684
+ revision: number;
685
+ byKey: Map<string, readonly SurfaceBlockSnapshot[]>;
686
+ } = { revision: -1, byKey: new Map() };
687
+ // P8 — per-revision cache for getDocumentEndnoteBlocks (single key).
688
+ let endnoteBlocksCache: {
689
+ revision: number;
690
+ blocks: readonly PublicRegionBlock[] | null;
691
+ } = { revision: -1, blocks: null };
692
+
693
+ function resolveCanonicalDocument(): CanonicalDocumentEnvelope {
694
+ if (input.canonicalDocument) {
695
+ return input.canonicalDocument();
696
+ }
697
+ return getQueryInput().document;
698
+ }
699
+
700
+ /**
701
+ * Build the body story's `SurfaceBlockSnapshot[]` so body fragment
702
+ * `blockId`s can be resolved. Cached against the canonical document
703
+ * identity to avoid re-walking the body on every region read.
704
+ */
705
+ let cachedBodySurface: {
706
+ document: CanonicalDocumentEnvelope;
707
+ blocks: readonly SurfaceBlockSnapshot[];
708
+ } | null = null;
709
+ function bodySurfaceBlocks(): readonly SurfaceBlockSnapshot[] {
710
+ const document = resolveCanonicalDocument();
711
+ if (cachedBodySurface && cachedBodySurface.document === document) {
712
+ return cachedBodySurface.blocks;
713
+ }
714
+ const surface = createEditorSurfaceSnapshot(
715
+ document,
716
+ createSelectionSnapshot(0, 0),
717
+ MAIN_STORY_TARGET,
718
+ );
719
+ cachedBodySurface = { document, blocks: surface.blocks };
720
+ return surface.blocks;
721
+ }
722
+
723
+ function findBodyBlockById(
724
+ blockId: string,
725
+ ): SurfaceBlockSnapshot | undefined {
726
+ return bodySurfaceBlocks().find((b) => b.blockId === blockId);
727
+ }
728
+
729
+ function getRegionBlocksCached(
730
+ pageIndex: number,
731
+ region: PublicPageRegion["kind"],
732
+ columnIndex: number | undefined,
733
+ ): readonly PublicRegionBlock[] {
734
+ const graph = currentGraph();
735
+ if (regionBlocksCache.revision !== graph.revision) {
736
+ regionBlocksCache = { revision: graph.revision, byKey: new Map() };
737
+ // P8.2.1 — bust the secondary story-projection cache in lock-step so
738
+ // shared `SurfaceBlockSnapshot`s never outlive their graph revision.
739
+ storyProjectionCache = { revision: graph.revision, byKey: new Map() };
740
+ // Body surface depends on the canonical document; the document
741
+ // identity check inside `bodySurfaceBlocks` keeps it warm across
742
+ // pure-layout invalidations (zoom, font, etc.).
743
+ }
744
+ const key = `${pageIndex}:${region}:${columnIndex ?? "_"}`;
745
+ const cached = regionBlocksCache.byKey.get(key);
746
+ if (cached) return cached;
747
+ const fresh = computeRegionBlocks(pageIndex, region, columnIndex, graph);
748
+ regionBlocksCache.byKey.set(key, fresh);
749
+ return fresh;
750
+ }
751
+
752
+ /**
753
+ * P8.2.1 — shared projection for a header/footer story. Returns the same
754
+ * `SurfaceBlockSnapshot[]` array identity for repeated calls at the same
755
+ * revision so per-page `PublicRegionBlock[]`s can share every
756
+ * `blockSnapshot` reference.
757
+ */
758
+ function getStoryProjectionCached(
759
+ storyTarget: EditorStoryTarget,
760
+ document: CanonicalDocumentEnvelope,
761
+ ): readonly SurfaceBlockSnapshot[] {
762
+ const key = storyTargetKey(storyTarget);
763
+ const cached = storyProjectionCache.byKey.get(key);
764
+ if (cached) return cached;
765
+ const surface = createEditorSurfaceSnapshot(
766
+ document,
767
+ createSelectionSnapshot(0, 0),
768
+ storyTarget,
769
+ );
770
+ const fresh = surface.blocks;
771
+ storyProjectionCache.byKey.set(key, fresh);
772
+ return fresh;
773
+ }
774
+
775
+ function computeRegionBlocks(
776
+ pageIndex: number,
777
+ region: PublicPageRegion["kind"],
778
+ columnIndex: number | undefined,
779
+ graph: RuntimePageGraph,
780
+ ): readonly PublicRegionBlock[] {
781
+ const node = graph.pages[pageIndex];
782
+ if (!node) return Object.freeze([]);
783
+ const document = resolveCanonicalDocument();
784
+ if (region === "body") {
785
+ return resolveBodyRegionBlocks(node, graph, findBodyBlockById);
786
+ }
787
+ if (region === "header" || region === "footer") {
788
+ const story =
789
+ region === "header" ? node.stories.header : node.stories.footer;
790
+ if (!story) return Object.freeze([]);
791
+ // P8.2.1 — share the projected `SurfaceBlockSnapshot[]` across every
792
+ // page that renders the same story target.
793
+ const projectedBlocks = getStoryProjectionCached(story, document);
794
+ return resolveHeaderFooterRegionBlocks(
795
+ node.pageIndex,
796
+ region,
797
+ story,
798
+ projectedBlocks,
799
+ );
800
+ }
801
+ if (region === "footnote-area") {
802
+ return resolveFootnoteAreaRegionBlocks(node, document);
803
+ }
804
+ if (region === "column") {
805
+ // Multi-column body splitting is a P10 follow-up. For now reuse
806
+ // the body fragments for the indexed column.
807
+ void columnIndex;
808
+ return resolveBodyRegionBlocks(node, graph, findBodyBlockById);
809
+ }
810
+ // endnote-area: empty per-page; document-end via getDocumentEndnoteBlocks.
811
+ return Object.freeze([]);
812
+ }
813
+
596
814
  return {
597
815
  getPageCount() {
598
816
  return currentGraph().pages.length;
@@ -732,6 +950,69 @@ export function createLayoutFacet(
732
950
  return result;
733
951
  },
734
952
 
953
+ getStoryBlocksForRegion(pageIndex, region, options) {
954
+ return getRegionBlocksCached(pageIndex, region, options?.columnIndex);
955
+ },
956
+
957
+ getDocumentEndnoteBlocks() {
958
+ const graph = currentGraph();
959
+ if (endnoteBlocksCache.revision === graph.revision && endnoteBlocksCache.blocks) {
960
+ return endnoteBlocksCache.blocks;
961
+ }
962
+ const document = resolveCanonicalDocument();
963
+ const collection = document.subParts?.footnoteCollection;
964
+ const blocks: PublicRegionBlock[] = [];
965
+ if (collection) {
966
+ // P8.2.1 — batch every endnote body into one
967
+ // `createEditorSurfaceSnapshot` call. Previously we called the
968
+ // projector once per block (50 endnotes × 1 block → 50 full
969
+ // projections); now we project the flat concatenation once and
970
+ // slice the result back into per-note groups using the original
971
+ // block counts. `runtimeFromOffset` / `runtimeToOffset` inherit
972
+ // the projection's cumulative cursor (a stable "all-endnotes"
973
+ // meta-story offset); fragment ids still scope per-note so
974
+ // consumers can tell which endnote a block belongs to.
975
+ const groups: Array<{ noteId: string; count: number }> = [];
976
+ const flat: import("../../model/canonical-document.ts").BlockNode[] = [];
977
+ for (const noteId of Object.keys(collection.endnotes)) {
978
+ const note = collection.endnotes[noteId];
979
+ if (!note || note.blocks.length === 0) continue;
980
+ groups.push({ noteId, count: note.blocks.length });
981
+ for (const block of note.blocks) flat.push(block);
982
+ }
983
+ if (flat.length > 0) {
984
+ const surface = createEditorSurfaceSnapshot(
985
+ {
986
+ ...document,
987
+ content: { type: "doc", children: flat },
988
+ } as CanonicalDocumentEnvelope,
989
+ createSelectionSnapshot(0, 0),
990
+ );
991
+ let cursor = 0;
992
+ for (const group of groups) {
993
+ for (let i = 0; i < group.count; i += 1) {
994
+ const blockSnapshot = surface.blocks[cursor + i];
995
+ if (!blockSnapshot) continue;
996
+ blocks.push({
997
+ blockId: blockSnapshot.blockId,
998
+ fragmentId: `endnote-${group.noteId}-${i}`,
999
+ pageIndex: -1,
1000
+ regionKind: "endnote-area",
1001
+ runtimeFromOffset: blockSnapshot.from,
1002
+ runtimeToOffset: blockSnapshot.to,
1003
+ heightTwips: 0,
1004
+ blockSnapshot,
1005
+ });
1006
+ }
1007
+ cursor += group.count;
1008
+ }
1009
+ }
1010
+ }
1011
+ const frozen = Object.freeze(blocks);
1012
+ endnoteBlocksCache = { revision: graph.revision, blocks: frozen };
1013
+ return frozen;
1014
+ },
1015
+
735
1016
  getPageFormatCatalog() {
736
1017
  return PAGE_FORMAT_CATALOG;
737
1018
  },
@@ -1005,6 +1286,9 @@ function toPublicPageRegions(regions: RuntimePageRegions): PublicPageRegions {
1005
1286
  ...(regions.header ? { header: toPublicPageRegion(regions.header) } : {}),
1006
1287
  ...(regions.footer ? { footer: toPublicPageRegion(regions.footer) } : {}),
1007
1288
  ...(regions.columns ? { columns: regions.columns.map(toPublicPageRegion) } : {}),
1289
+ ...(regions.footnotes && regions.footnotes.length > 0
1290
+ ? { footnotes: regions.footnotes.map(toPublicPageRegion) }
1291
+ : {}),
1008
1292
  };
1009
1293
  }
1010
1294
 
@@ -1648,3 +1932,118 @@ function findCanonicalTableByBlockId(
1648
1932
  }
1649
1933
  return null;
1650
1934
  }
1935
+
1936
+ // ---------------------------------------------------------------------------
1937
+ // P8 — region-block resolvers
1938
+ // ---------------------------------------------------------------------------
1939
+
1940
+ function resolveBodyRegionBlocks(
1941
+ node: RuntimePageNode,
1942
+ graph: RuntimePageGraph,
1943
+ findBodyBlockById: (blockId: string) => SurfaceBlockSnapshot | undefined,
1944
+ ): readonly PublicRegionBlock[] {
1945
+ const fragmentsById = new Map<string, RuntimeBlockFragment>();
1946
+ for (const fragment of graph.fragments) {
1947
+ fragmentsById.set(fragment.fragmentId, fragment);
1948
+ }
1949
+ const blocks: PublicRegionBlock[] = [];
1950
+ for (const fragmentId of node.regions.body.fragmentIds) {
1951
+ const fragment = fragmentsById.get(fragmentId);
1952
+ if (!fragment) continue;
1953
+ const blockSnapshot = findBodyBlockById(fragment.blockId);
1954
+ if (!blockSnapshot) continue;
1955
+ blocks.push({
1956
+ blockId: fragment.blockId,
1957
+ fragmentId,
1958
+ pageIndex: node.pageIndex,
1959
+ regionKind: "body",
1960
+ runtimeFromOffset: fragment.from,
1961
+ runtimeToOffset: fragment.to,
1962
+ heightTwips: fragment.heightTwips,
1963
+ blockSnapshot,
1964
+ });
1965
+ }
1966
+ return Object.freeze(blocks);
1967
+ }
1968
+
1969
+ function resolveHeaderFooterRegionBlocks(
1970
+ pageIndex: number,
1971
+ regionKind: "header" | "footer",
1972
+ storyTarget: EditorStoryTarget,
1973
+ projectedBlocks: readonly SurfaceBlockSnapshot[],
1974
+ ): readonly PublicRegionBlock[] {
1975
+ if (storyTarget.kind !== regionKind) return Object.freeze([]);
1976
+ if (projectedBlocks.length === 0) return Object.freeze([]);
1977
+ const fragmentBase = `story-${storyTarget.kind}-${storyTarget.relationshipId}-${storyTarget.variant}-${storyTarget.sectionIndex ?? "_"}`;
1978
+ return Object.freeze(
1979
+ projectedBlocks.map((blockSnapshot, index): PublicRegionBlock => ({
1980
+ blockId: blockSnapshot.blockId,
1981
+ fragmentId: `${fragmentBase}-${index}`,
1982
+ pageIndex,
1983
+ regionKind,
1984
+ runtimeFromOffset: blockSnapshot.from,
1985
+ runtimeToOffset: blockSnapshot.to,
1986
+ // Header/footer fragments aren't measured per-block — consumers read
1987
+ // the region's `heightTwips` from `getStoryRegionsOnPage`.
1988
+ heightTwips: 0,
1989
+ blockSnapshot,
1990
+ })),
1991
+ );
1992
+ }
1993
+
1994
+ function resolveFootnoteAreaRegionBlocks(
1995
+ node: RuntimePageNode,
1996
+ document: CanonicalDocumentEnvelope,
1997
+ ): readonly PublicRegionBlock[] {
1998
+ const collection = document.subParts?.footnoteCollection;
1999
+ if (!collection) return Object.freeze([]);
2000
+ // P8.2.1 — batch every allocation's body into one
2001
+ // `createEditorSurfaceSnapshot` call and slice the result back into
2002
+ // per-allocation groups. Amplification per-page is small (a handful of
2003
+ // footnotes), but this mirrors the endnote batching so both paths take
2004
+ // the same shape. The existence guard on `note` is sufficient — the
2005
+ // previous `void note;` marker is gone.
2006
+ const groups: Array<{
2007
+ allocation: RuntimeNoteAllocation;
2008
+ count: number;
2009
+ }> = [];
2010
+ const flat: import("../../model/canonical-document.ts").BlockNode[] = [];
2011
+ for (const allocation of node.noteAllocations) {
2012
+ if (allocation.noteKind !== "footnote") continue;
2013
+ const note = collection.footnotes[allocation.noteId];
2014
+ if (!note || note.blocks.length === 0) continue;
2015
+ groups.push({ allocation, count: note.blocks.length });
2016
+ for (const block of note.blocks) flat.push(block);
2017
+ }
2018
+ if (flat.length === 0) return Object.freeze([]);
2019
+ const surface = createEditorSurfaceSnapshot(
2020
+ {
2021
+ ...document,
2022
+ content: { type: "doc", children: flat },
2023
+ } as CanonicalDocumentEnvelope,
2024
+ createSelectionSnapshot(0, 0),
2025
+ );
2026
+ const blocks: PublicRegionBlock[] = [];
2027
+ let cursor = 0;
2028
+ for (const group of groups) {
2029
+ const fragmentBase = group.allocation.fragmentId
2030
+ ?? `note-${node.pageIndex}-${group.allocation.noteId}`;
2031
+ for (let i = 0; i < group.count; i += 1) {
2032
+ const blockSnapshot = surface.blocks[cursor + i];
2033
+ if (!blockSnapshot) continue;
2034
+ blocks.push({
2035
+ blockId: blockSnapshot.blockId,
2036
+ fragmentId: `${fragmentBase}-${i}`,
2037
+ pageIndex: node.pageIndex,
2038
+ regionKind: "footnote-area",
2039
+ runtimeFromOffset: blockSnapshot.from,
2040
+ runtimeToOffset: blockSnapshot.to,
2041
+ heightTwips: group.allocation.reservedHeightTwips,
2042
+ blockSnapshot,
2043
+ });
2044
+ }
2045
+ cursor += group.count;
2046
+ }
2047
+ return Object.freeze(blocks);
2048
+ }
2049
+
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Lightweight string-keyed counters for L7 render-perf instrumentation.
3
+ *
4
+ * Used by the runtime to prove (in tests and benchmarks) that a given
5
+ * facet rebuild ran or did not run during a sequence of operations.
6
+ * Phase 0 wires `refresh.all`; Phase 1 will add `facet.<name>.build`
7
+ * keyed on the per-facet builder.
8
+ *
9
+ * Cost guarantee: a `Map<string, number>` increment is < 100 ns. We do
10
+ * not need atomic counters — JS is single-threaded — and we deliberately
11
+ * keep the surface tiny so this module can never become a perf footgun
12
+ * on its own.
13
+ */
14
+ export class PerfCounters {
15
+ private readonly counts = new Map<string, number>();
16
+
17
+ increment(key: string, delta = 1): void {
18
+ this.counts.set(key, (this.counts.get(key) ?? 0) + delta);
19
+ }
20
+
21
+ snapshot(): Record<string, number> {
22
+ return Object.fromEntries(this.counts);
23
+ }
24
+
25
+ reset(): void {
26
+ this.counts.clear();
27
+ }
28
+ }
@@ -56,6 +56,23 @@ export interface RenderPageRegions {
56
56
  footer?: RenderStoryRegion;
57
57
  columns?: readonly RenderStoryRegion[];
58
58
  footnoteArea?: RenderStoryRegion;
59
+ /**
60
+ * P8.3 — footnote areas reserved at the bottom of the page (above the
61
+ * footer band). Mirrors the page graph's `RuntimePageRegions.footnotes`
62
+ * — one entry per allocated region (typically one per page today, but
63
+ * shape allows for multiple should allocation-splitting land).
64
+ * Populated only when `PublicPageRegions.footnotes` is non-empty.
65
+ * Additive — back-compat safe; consumers that ignore this field see no
66
+ * change.
67
+ */
68
+ footnotes?: readonly RenderStoryRegion[];
69
+ /**
70
+ * P8.3 — endnote regions. Reserved seam; per-page endnote projection is
71
+ * NOT populated today (endnotes use document-end placement via
72
+ * `facet.getDocumentEndnoteBlocks()`). Shape exists so a future
73
+ * per-section endnote renderer has a stable read surface.
74
+ */
75
+ endnotes?: readonly RenderStoryRegion[];
59
76
  }
60
77
 
61
78
  export interface RenderStoryRegion {