@beyondwork/docx-react-component 1.0.42 → 1.0.45

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 (82) hide show
  1. package/README.md +17 -0
  2. package/package.json +5 -4
  3. package/src/api/editor-state-types.ts +110 -0
  4. package/src/api/public-types.ts +333 -4
  5. package/src/core/commands/formatting-commands.ts +7 -1
  6. package/src/core/commands/index.ts +60 -10
  7. package/src/core/commands/text-commands.ts +59 -0
  8. package/src/core/search/search-text.ts +15 -2
  9. package/src/core/selection/review-anchors.ts +131 -21
  10. package/src/index.ts +29 -1
  11. package/src/io/chart-preview-resolver.ts +281 -0
  12. package/src/io/docx-session.ts +692 -2
  13. package/src/io/export/build-app-properties-xml.ts +1 -1
  14. package/src/io/export/serialize-comments.ts +38 -9
  15. package/src/io/export/twip.ts +1 -1
  16. package/src/io/load-scheduler.ts +230 -0
  17. package/src/io/normalize/normalize-text.ts +116 -0
  18. package/src/io/ooxml/parse-comments.ts +0 -33
  19. package/src/io/ooxml/parse-complex-content.ts +14 -0
  20. package/src/io/ooxml/parse-main-document.ts +4 -0
  21. package/src/io/ooxml/workflow-payload-validator.ts +97 -1
  22. package/src/io/ooxml/workflow-payload.ts +172 -1
  23. package/src/preservation/opaque-region.ts +5 -0
  24. package/src/review/store/comment-remapping.ts +2 -2
  25. package/src/runtime/collab-session.ts +1 -1
  26. package/src/runtime/document-runtime.ts +661 -42
  27. package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
  28. package/src/runtime/edit-dispatch/index.ts +2 -0
  29. package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
  30. package/src/runtime/editor-state-channel.ts +544 -0
  31. package/src/runtime/editor-state-integration.ts +217 -0
  32. package/src/runtime/editor-surface/capabilities.ts +411 -0
  33. package/src/runtime/layout/index.ts +2 -0
  34. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  35. package/src/runtime/layout/layout-engine-instance.ts +63 -2
  36. package/src/runtime/layout/layout-engine-version.ts +41 -0
  37. package/src/runtime/layout/paginated-layout-engine.ts +211 -14
  38. package/src/runtime/layout/public-facet.ts +430 -1
  39. package/src/runtime/perf-counters.ts +28 -0
  40. package/src/runtime/prerender/cache-envelope.ts +29 -0
  41. package/src/runtime/prerender/cache-key.ts +66 -0
  42. package/src/runtime/prerender/font-fingerprint.ts +17 -0
  43. package/src/runtime/prerender/graph-canonicalize.ts +121 -0
  44. package/src/runtime/prerender/indexeddb-cache.ts +184 -0
  45. package/src/runtime/prerender/prerender-document.ts +145 -0
  46. package/src/runtime/render/block-fragment-projection.ts +2 -0
  47. package/src/runtime/render/render-frame-types.ts +17 -0
  48. package/src/runtime/render/render-kernel.ts +172 -29
  49. package/src/runtime/selection/post-edit-validator.ts +77 -0
  50. package/src/runtime/surface-projection.ts +45 -7
  51. package/src/runtime/workflow-markup.ts +71 -16
  52. package/src/ui/WordReviewEditor.tsx +142 -237
  53. package/src/ui/editor-command-bag.ts +14 -0
  54. package/src/ui/editor-runtime-boundary.ts +115 -12
  55. package/src/ui/editor-shell-view.tsx +10 -0
  56. package/src/ui/editor-surface-controller.tsx +5 -0
  57. package/src/ui/headless/selection-helpers.ts +10 -0
  58. package/src/ui/runtime-shortcut-dispatch.ts +28 -68
  59. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  60. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  61. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
  62. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  63. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  64. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
  65. package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
  66. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
  67. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  68. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  69. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
  70. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +47 -0
  71. package/src/ui-tailwind/index.ts +5 -1
  72. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  73. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  74. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  75. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  76. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  77. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  78. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
  79. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  80. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  81. package/src/ui-tailwind/theme/editor-theme.css +47 -14
  82. package/src/ui-tailwind/tw-review-workspace.tsx +303 -123
@@ -182,6 +182,35 @@ export interface LayoutEngineInstance {
182
182
  * glyphs, and the cached page graph keeps its stale page boundaries.
183
183
  */
184
184
  invalidateMeasurementCache(): void;
185
+
186
+ // ---- cache rehydration (L7 Phase 2.5) ---------------------------------
187
+ /**
188
+ * Seed the engine's cached graph from a prerendered `RuntimePageGraph`
189
+ * (read from IndexedDB or customXml). Subsequent `getPageGraph()` calls
190
+ * against the same `document` return the seeded graph directly —
191
+ * skipping `fullRebuild` and the pagination/measurement work that
192
+ * dominates cold-open on large docs.
193
+ *
194
+ * Both the graph and the source document must be seeded so the
195
+ * engine's internal cache-key (`content`, `styles`, `subParts`
196
+ * reference-equality tuple) compares equal on the next query. Passing
197
+ * only the graph leaves `cachedKey` null, the next query would run a
198
+ * full rebuild, and the seed would be discarded.
199
+ *
200
+ * Caller contract:
201
+ * - The seeded graph must have been produced by the same
202
+ * LAYOUT_ENGINE_VERSION as the current engine instance. Stale reads
203
+ * are prevented by the cache-key scheme in
204
+ * src/runtime/prerender/cache-key.ts (engine version is part of the
205
+ * key), not by this method.
206
+ * - The next runtime mutation triggers a normal invalidation path;
207
+ * the seeded graph is treated as a fresh cache entry from the
208
+ * engine's perspective.
209
+ */
210
+ seedCachedGraph(
211
+ graph: RuntimePageGraph,
212
+ document: CanonicalDocumentEnvelope,
213
+ ): void;
185
214
  }
186
215
 
187
216
  // ---------------------------------------------------------------------------
@@ -296,16 +325,24 @@ export function createLayoutEngine(
296
325
  );
297
326
  const pages = pageStack.pages;
298
327
  const stories = resolvePageStories(pages);
299
- const fragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
328
+ const bodyFragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
300
329
  mainSurface,
301
330
  pages,
302
331
  pageStack.splits,
303
332
  );
333
+ // P8.1b — merge per-note fragments (regionKind: "footnote-area") into the
334
+ // main fragments map so buildPageGraph sees them alongside body fragments.
335
+ const fragmentsByPageIndex = new Map(bodyFragmentsByPageIndex);
336
+ for (const [pageIndex, noteFragments] of (pageStack.noteFragmentsByPageIndex ?? new Map())) {
337
+ const existing = fragmentsByPageIndex.get(pageIndex) ?? [];
338
+ fragmentsByPageIndex.set(pageIndex, [...existing, ...noteFragments]);
339
+ }
304
340
  const graph = buildPageGraph({
305
341
  pages,
306
342
  sections,
307
343
  stories,
308
344
  fragmentsByPageIndex,
345
+ noteAllocationsByPageIndex: pageStack.noteAllocationsByPageIndex,
309
346
  });
310
347
 
311
348
  // Field dirtiness diff from previous graph
@@ -413,16 +450,23 @@ export function createLayoutEngine(
413
450
  const freshStories = resolvePageStories(freshSnapshots);
414
451
  // Project fragments for the fresh tail pages, threading paragraph
415
452
  // line-range splits produced by intra-paragraph pagination.
416
- const freshFragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
453
+ const freshBodyFragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
417
454
  mainSurface,
418
455
  freshSnapshots,
419
456
  freshResult.splits,
420
457
  );
458
+ // P8.1b — merge per-note fragments into the fresh fragments map.
459
+ const freshFragmentsByPageIndex = new Map(freshBodyFragmentsByPageIndex);
460
+ for (const [pageIndex, noteFragments] of (freshResult.noteFragmentsByPageIndex ?? new Map())) {
461
+ const existing = freshFragmentsByPageIndex.get(pageIndex) ?? [];
462
+ freshFragmentsByPageIndex.set(pageIndex, [...existing, ...noteFragments]);
463
+ }
421
464
  const freshGraph = buildPageGraph({
422
465
  pages: freshSnapshots,
423
466
  sections,
424
467
  stories: freshStories,
425
468
  fragmentsByPageIndex: freshFragmentsByPageIndex,
469
+ noteAllocationsByPageIndex: freshResult.noteAllocationsByPageIndex,
426
470
  });
427
471
  const freshNodes = freshGraph.pages;
428
472
 
@@ -722,6 +766,23 @@ export function createLayoutEngine(
722
766
  cachedFormatting = null;
723
767
  cachedMapper = null;
724
768
  },
769
+
770
+ /**
771
+ * L7 Phase 2.5 — seed the cached graph from a prerender envelope.
772
+ * Populates both `cachedGraph` and `cachedKey` (keyed on the provided
773
+ * document's identity-equal slots) so the next getPageGraph query
774
+ * returns the seeded graph directly. Any subsequent mutation
775
+ * invalidates normally through the existing path.
776
+ */
777
+ seedCachedGraph(graph: RuntimePageGraph, document: CanonicalDocumentEnvelope) {
778
+ cachedGraph = graph;
779
+ cachedKey = {
780
+ content: document.content,
781
+ styles: document.styles,
782
+ subParts: document.subParts,
783
+ };
784
+ previousPageCount = graph.contentPageCount;
785
+ },
725
786
  };
726
787
  }
727
788
 
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Layout engine version marker — bump when **any** file under
3
+ * `src/runtime/layout/**` or `src/runtime/render/**` changes, or when
4
+ * the page-break widget DOM shape under `src/ui-tailwind/editor-surface/`
5
+ * changes in a way that affects cached render-frame diffs or persisted
6
+ * layout caches.
7
+ *
8
+ * Enforcement: `scripts/ci-check-layout-engine-version.mjs` inspects the
9
+ * PR diff; if any file in the watched trees is touched without this
10
+ * constant being co-touched, CI fails. See CLAUDE.md → *Performance
11
+ * Invariants* for the full contract.
12
+ *
13
+ * Persisted caches should key their stored snapshots on this version so a
14
+ * bump automatically invalidates stale entries — no corruption path
15
+ * exists because the version is the cache's top-level discriminator.
16
+ *
17
+ * History:
18
+ * 1 — initial materialization (L8 Phase B).
19
+ * 2 — L8 Phase B retired the page-posture widget's inline band-gap-band
20
+ * DOM in favor of a transparent spacer. Widget DOM shape changed;
21
+ * cached render frames from version 1 are incompatible.
22
+ * 3 — L7 Phase 2.5 Plan A. Adds `seedCachedGraph(graph, document)` to
23
+ * `LayoutEngineInstance` so prerender-cache consumers can seed the
24
+ * internal cachedGraph + cachedKey without triggering fullRebuild.
25
+ * Does not change geometry — but the public interface changed, so
26
+ * persisted envelopes MUST re-derive their cacheKey under 3.
27
+ */
28
+ export const LAYOUT_ENGINE_VERSION = 3 as const;
29
+
30
+ /**
31
+ * Serialization schema version for the LayCache payload (the cache envelope
32
+ * stored in IndexedDB, and — post Plan B — the customXml editor-state
33
+ * namespace). Bump independently of LAYOUT_ENGINE_VERSION when the
34
+ * envelope shape changes but the layout engine itself has not.
35
+ *
36
+ * History:
37
+ * 1 — initial envelope shape: { schemaVersion, engineVersion,
38
+ * fontFingerprint, structuralHash, graph, surface }. Ships with
39
+ * L7 Phase 2.5 Plan A.
40
+ */
41
+ export const LAYCACHE_SCHEMA_VERSION = 1 as const;
@@ -41,6 +41,10 @@ import type {
41
41
  FootnoteCollection,
42
42
  } from "../../model/canonical-document.ts";
43
43
  import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
44
+ import type {
45
+ RuntimeNoteAllocation,
46
+ RuntimeBlockFragment,
47
+ } from "./page-graph.ts";
44
48
  import {
45
49
  buildPageLayoutSnapshot,
46
50
  buildResolvedSections,
@@ -138,6 +142,19 @@ export interface BlockSplits {
138
142
  export interface PageStackResultWithSplits {
139
143
  pages: DocumentPageSnapshot[];
140
144
  splits: BlockSplits;
145
+ /**
146
+ * P8.1b — per-page note allocations emitted by the engine.
147
+ * Keyed by zero-based global page index.
148
+ * Absent entries mean the page has no footnotes.
149
+ * Endnote allocations are deferred to P8 Task 7.
150
+ */
151
+ noteAllocationsByPageIndex?: ReadonlyMap<number, RuntimeNoteAllocation[]>;
152
+ /**
153
+ * P8.1b — per-page note body fragments emitted by the engine.
154
+ * Each fragment has `regionKind: "footnote-area"`.
155
+ * Parallel to `noteAllocationsByPageIndex`.
156
+ */
157
+ noteFragmentsByPageIndex?: ReadonlyMap<number, Array<Omit<RuntimeBlockFragment, "pageId">>>;
141
158
  }
142
159
 
143
160
  // ---------------------------------------------------------------------------
@@ -182,6 +199,13 @@ export function buildPageStackWithSplits(
182
199
  ): PageStackResultWithSplits {
183
200
  const pages: DocumentPageSnapshot[] = [];
184
201
  const splitsByBlock = new Map<string, ParagraphLineSlice[]>();
202
+ // P8.1b — aggregate note allocations and fragments across all sections,
203
+ // keyed by global page index.
204
+ const globalNoteAllocationsByPageIndex = new Map<number, RuntimeNoteAllocation[]>();
205
+ const globalNoteFragmentsByPageIndex = new Map<
206
+ number,
207
+ Array<Omit<RuntimeBlockFragment, "pageId">>
208
+ >();
185
209
  let globalPageIndex = 0;
186
210
  // A single cache lives for the whole pagination pass so cross-section
187
211
  // re-measurement (rare but possible through keepNext heuristics) still
@@ -305,6 +329,21 @@ export function buildPageStackWithSplits(
305
329
  }
306
330
  if (existing.length > 0) splitsByBlock.set(blockId, existing);
307
331
  }
332
+
333
+ // P8.1b — resolve per-section note allocations + fragments to global
334
+ // page index and merge into the global maps.
335
+ for (const [pageInSec, sectionAllocs] of paginatedResult.noteAllocationsByPageInSection) {
336
+ const globalPageIdx = pageInSectionToGlobal.get(pageInSec);
337
+ if (globalPageIdx === undefined) continue;
338
+ const existing = globalNoteAllocationsByPageIndex.get(globalPageIdx) ?? [];
339
+ globalNoteAllocationsByPageIndex.set(globalPageIdx, [...existing, ...sectionAllocs]);
340
+ }
341
+ for (const [pageInSec, sectionFrags] of paginatedResult.noteFragmentsByPageInSection) {
342
+ const globalPageIdx = pageInSectionToGlobal.get(pageInSec);
343
+ if (globalPageIdx === undefined) continue;
344
+ const existing = globalNoteFragmentsByPageIndex.get(globalPageIdx) ?? [];
345
+ globalNoteFragmentsByPageIndex.set(globalPageIdx, [...existing, ...sectionFrags]);
346
+ }
308
347
  }
309
348
 
310
349
  // Guarantee at least one page
@@ -328,6 +367,12 @@ export function buildPageStackWithSplits(
328
367
  return {
329
368
  pages,
330
369
  splits: { byBlockId: splitsByBlock, tablesByBlockId: tableSplitsByBlock },
370
+ noteAllocationsByPageIndex: globalNoteAllocationsByPageIndex.size > 0
371
+ ? globalNoteAllocationsByPageIndex
372
+ : undefined,
373
+ noteFragmentsByPageIndex: globalNoteFragmentsByPageIndex.size > 0
374
+ ? globalNoteFragmentsByPageIndex
375
+ : undefined,
331
376
  };
332
377
  }
333
378
 
@@ -984,6 +1029,10 @@ interface SectionLocalSlice {
984
1029
  interface SectionPaginationResult {
985
1030
  pages: Omit<DocumentPageSnapshot, "pageIndex">[];
986
1031
  splits: { byBlockId: Map<string, SectionLocalSlice[]> };
1032
+ /** P8.1b — per-page note allocations keyed by pageInSection index. */
1033
+ noteAllocationsByPageInSection: Map<number, RuntimeNoteAllocation[]>;
1034
+ /** P8.1b — per-page note body fragments keyed by pageInSection index. */
1035
+ noteFragmentsByPageInSection: Map<number, Array<Omit<RuntimeBlockFragment, "pageId">>>;
987
1036
  }
988
1037
 
989
1038
  /**
@@ -1009,7 +1058,7 @@ function paginateSectionBlocks(
1009
1058
  ).pages;
1010
1059
  }
1011
1060
 
1012
- function paginateSectionBlocksWithSplits(
1061
+ export function paginateSectionBlocksWithSplits(
1013
1062
  section: ResolvedDocumentSection,
1014
1063
  blocks: readonly SurfaceBlockSnapshot[],
1015
1064
  layout: DocumentPageSnapshot["layout"],
@@ -1029,6 +1078,8 @@ function paginateSectionBlocksWithSplits(
1029
1078
  },
1030
1079
  ],
1031
1080
  splits: { byBlockId: new Map() }, // section-local; global map includes tablesByBlockId via collectTableRowSlices
1081
+ noteAllocationsByPageInSection: new Map(),
1082
+ noteFragmentsByPageInSection: new Map(),
1032
1083
  };
1033
1084
  }
1034
1085
 
@@ -1048,14 +1099,104 @@ function paginateSectionBlocksWithSplits(
1048
1099
  // table is fully placed.
1049
1100
  const tableProgress = new Map<string, number>();
1050
1101
 
1102
+ // P8.1b — per-page note tracking.
1103
+ // `pendingNoteKeys` parallels `reservedNotes` but is only snapshotted on
1104
+ // page-push (finalization), NOT on column break.
1105
+ // `pendingNoteBlockFroms` records the referencing block's `from` offset
1106
+ // for each note key, enabling hit-test via `RuntimeBlockFragment.from`.
1107
+ const noteAllocationsByPageInSection = new Map<number, RuntimeNoteAllocation[]>();
1108
+ const noteFragmentsByPageInSection = new Map<
1109
+ number,
1110
+ Array<Omit<RuntimeBlockFragment, "pageId">>
1111
+ >();
1112
+ const pendingNoteKeys = new Set<string>();
1113
+ const pendingNoteBlockFroms = new Map<string, { blockFrom: number; blockTo: number }>();
1114
+ // Track the columnWidth at the time each note was accumulated so the
1115
+ // measurement is reproducible at page-close time.
1116
+ const pendingNoteColumnWidths = new Map<string, number>();
1117
+
1118
+ /**
1119
+ * Snapshot the pending note state into `noteAllocationsByPageInSection` and
1120
+ * `noteFragmentsByPageInSection` for the page that is about to close.
1121
+ * Must be called BEFORE clearing `pendingNoteKeys`.
1122
+ */
1123
+ const snapshotNoteAllocations = (closingPageInSection: number, columnWidth: number): void => {
1124
+ if (pendingNoteKeys.size === 0 || !footnotes) return;
1125
+
1126
+ const allocations: RuntimeNoteAllocation[] = [];
1127
+ const fragments: Array<Omit<RuntimeBlockFragment, "pageId">> = [];
1128
+ let orderInRegion = 0;
1129
+
1130
+ for (const noteKey of pendingNoteKeys) {
1131
+ const colonIdx = noteKey.indexOf(":");
1132
+ if (colonIdx === -1) continue;
1133
+ const noteKind = noteKey.slice(0, colonIdx) as "footnote" | "endnote";
1134
+ const noteId = noteKey.slice(colonIdx + 1);
1135
+
1136
+ // P8.1b: endnote allocations are deferred to P8 Task 7 (endnote area
1137
+ // component). The existing reservedNoteHeight math still reserves space
1138
+ // for endnotes, but we do not emit RuntimeNoteAllocation for them here.
1139
+ // See P8 plan, Task 7.
1140
+ if (noteKind === "endnote") continue;
1141
+
1142
+ const effectiveColumnWidth =
1143
+ pendingNoteColumnWidths.get(noteKey) ?? columnWidth;
1144
+ const { heightTwips } = measureNoteBody(
1145
+ noteKind,
1146
+ noteId,
1147
+ footnotes,
1148
+ effectiveColumnWidth,
1149
+ );
1150
+
1151
+ const fragmentId = `note-${closingPageInSection}-${noteKind}-${noteId}`;
1152
+ const refRange = pendingNoteBlockFroms.get(noteKey) ?? {
1153
+ blockFrom: pageStart,
1154
+ blockTo: pageStart + 1,
1155
+ };
1156
+
1157
+ const allocation: RuntimeNoteAllocation = {
1158
+ noteKind,
1159
+ noteId,
1160
+ reservedHeightTwips: heightTwips + FOOTNOTE_REFERENCE_RESERVATION_TWIPS,
1161
+ fragmentId,
1162
+ };
1163
+
1164
+ const fragment: Omit<RuntimeBlockFragment, "pageId"> = {
1165
+ fragmentId,
1166
+ blockId: `note-body-${noteKind}-${noteId}`,
1167
+ orderInRegion,
1168
+ regionKind: "footnote-area",
1169
+ from: refRange.blockFrom,
1170
+ to: refRange.blockTo,
1171
+ heightTwips: heightTwips + FOOTNOTE_REFERENCE_RESERVATION_TWIPS,
1172
+ kind: "whole",
1173
+ };
1174
+
1175
+ allocations.push(allocation);
1176
+ fragments.push(fragment);
1177
+ orderInRegion += 1;
1178
+ }
1179
+
1180
+ if (allocations.length > 0) {
1181
+ noteAllocationsByPageInSection.set(closingPageInSection, allocations);
1182
+ noteFragmentsByPageInSection.set(closingPageInSection, fragments);
1183
+ }
1184
+ };
1185
+
1051
1186
  const pushPage = (endOffset: number): void => {
1052
1187
  const boundedEnd = Math.max(pageStart, Math.min(endOffset, section.end));
1053
1188
  if (boundedEnd === pageStart && pages.length > 0) {
1054
1189
  return;
1055
1190
  }
1191
+ const closingPageInSection = pageInSection;
1192
+ const columnWidth =
1193
+ columnMetrics[Math.min(columnIndex, columnMetrics.length - 1)]?.width ??
1194
+ getUsableColumnWidth(layout);
1195
+ // Snapshot note allocations for the page being closed BEFORE clearing state.
1196
+ snapshotNoteAllocations(closingPageInSection, columnWidth);
1056
1197
  pages.push({
1057
1198
  sectionIndex: section.index,
1058
- pageInSection,
1199
+ pageInSection: closingPageInSection,
1059
1200
  startOffset: pageStart,
1060
1201
  endOffset: boundedEnd,
1061
1202
  layout,
@@ -1066,6 +1207,10 @@ function paginateSectionBlocksWithSplits(
1066
1207
  columnIndex = 0;
1067
1208
  reservedNoteHeight = 0;
1068
1209
  reservedNotes.clear();
1210
+ // P8.1b: also clear pending note tracking on page finalization.
1211
+ pendingNoteKeys.clear();
1212
+ pendingNoteBlockFroms.clear();
1213
+ pendingNoteColumnWidths.clear();
1069
1214
  };
1070
1215
 
1071
1216
  for (let index = 0; index < blocks.length; index += 1) {
@@ -1183,10 +1328,15 @@ function paginateSectionBlocksWithSplits(
1183
1328
  // Overflow check — paragraph doesn't fit on current page
1184
1329
  if (projectedHeight > usableHeight && pageStart < block.from) {
1185
1330
  if (columnIndex < maxColumns - 1) {
1331
+ // Advance to next column without a page break — do NOT snapshot.
1186
1332
  columnIndex += 1;
1187
1333
  columnHeight = 0;
1188
1334
  reservedNoteHeight = 0;
1189
1335
  reservedNotes.clear();
1336
+ // P8.1b: clear pending note state WITHOUT snapshotting.
1337
+ pendingNoteKeys.clear();
1338
+ pendingNoteBlockFroms.clear();
1339
+ pendingNoteColumnWidths.clear();
1190
1340
  continue;
1191
1341
  }
1192
1342
 
@@ -1264,10 +1414,15 @@ function paginateSectionBlocksWithSplits(
1264
1414
  // span the full page if it's truly larger than a page).
1265
1415
  if (keepLinesActive && columnHeight > 0 && baseHeight > usableHeight - columnHeight && pageStart < block.from) {
1266
1416
  if (columnIndex < maxColumns - 1) {
1417
+ // Column advance without page break — do NOT snapshot.
1267
1418
  columnIndex += 1;
1268
1419
  columnHeight = 0;
1269
1420
  reservedNoteHeight = 0;
1270
1421
  reservedNotes.clear();
1422
+ // P8.1b: clear pending note state WITHOUT snapshotting.
1423
+ pendingNoteKeys.clear();
1424
+ pendingNoteBlockFroms.clear();
1425
+ pendingNoteColumnWidths.clear();
1271
1426
  continue;
1272
1427
  }
1273
1428
  pushPage(block.from);
@@ -1282,14 +1437,32 @@ function paginateSectionBlocksWithSplits(
1282
1437
  );
1283
1438
  columnHeight += baseHeight;
1284
1439
  reservedNoteHeight += effectiveNoteHeight;
1285
- currentPageNoteIds(block).forEach((noteKey) => reservedNotes.add(noteKey));
1440
+ currentPageNoteIds(block).forEach((noteKey) => {
1441
+ reservedNotes.add(noteKey);
1442
+ // P8.1b: also track the referencing block range for hit-test.
1443
+ // Only record the first reference (earliest block.from) per noteKey
1444
+ // so the fragment's from/to points to the paragraph that introduced it.
1445
+ if (!pendingNoteKeys.has(noteKey)) {
1446
+ pendingNoteKeys.add(noteKey);
1447
+ pendingNoteBlockFroms.set(noteKey, { blockFrom: block.from, blockTo: block.to });
1448
+ pendingNoteColumnWidths.set(noteKey, columnWidth);
1449
+ }
1450
+ });
1286
1451
 
1287
1452
  if (hasColumnBreak(block)) {
1288
1453
  if (columnIndex < maxColumns - 1) {
1454
+ // Column break within a multi-column layout: advance to next column.
1455
+ // DO NOT snapshot note allocations — only page-push triggers snapshotting.
1456
+ // Clear pending note state alongside reservedNotes so notes that only
1457
+ // appeared before the column break don't get double-counted.
1289
1458
  columnIndex += 1;
1290
1459
  columnHeight = 0;
1291
1460
  reservedNoteHeight = 0;
1292
1461
  reservedNotes.clear();
1462
+ // P8.1b: clear pending note state WITHOUT snapshotting.
1463
+ pendingNoteKeys.clear();
1464
+ pendingNoteBlockFroms.clear();
1465
+ pendingNoteColumnWidths.clear();
1293
1466
  } else {
1294
1467
  pushPage(nextBoundary);
1295
1468
  }
@@ -1317,9 +1490,37 @@ function paginateSectionBlocksWithSplits(
1317
1490
  },
1318
1491
  ],
1319
1492
  splits: { byBlockId: splitsByBlock },
1493
+ noteAllocationsByPageInSection,
1494
+ noteFragmentsByPageInSection,
1320
1495
  };
1321
1496
  }
1322
1497
 
1498
+ /**
1499
+ * Measure the height consumed by one note's body blocks, plus return those
1500
+ * blocks for use in building a `RuntimeBlockFragment`.
1501
+ *
1502
+ * Factored out of `estimateFootnoteReservation` so both the reservation-math
1503
+ * path and the P8.1b allocation-emission path share the same measurement.
1504
+ */
1505
+ function measureNoteBody(
1506
+ noteKind: "footnote" | "endnote",
1507
+ noteId: string,
1508
+ footnotes: FootnoteCollection,
1509
+ columnWidth: number,
1510
+ ): { heightTwips: number } {
1511
+ const noteCollection =
1512
+ noteKind === "endnote" ? footnotes.endnotes : footnotes.footnotes;
1513
+ const note = noteCollection[noteId];
1514
+ if (!note) {
1515
+ return { heightTwips: 0 };
1516
+ }
1517
+ const heightTwips = note.blocks.reduce(
1518
+ (total, child) => total + estimateCanonicalNoteBlockHeight(child, columnWidth),
1519
+ 0,
1520
+ );
1521
+ return { heightTwips };
1522
+ }
1523
+
1323
1524
  function estimateFootnoteReservation(
1324
1525
  block: SurfaceBlockSnapshot,
1325
1526
  footnotes: FootnoteCollection | undefined,
@@ -1336,17 +1537,13 @@ function estimateFootnoteReservation(
1336
1537
  continue;
1337
1538
  }
1338
1539
 
1339
- const [noteKind, noteId] = noteKey.split(":");
1340
- const noteCollection =
1341
- noteKind === "endnote" ? footnotes.endnotes : footnotes.footnotes;
1342
- const note = noteCollection[noteId];
1343
- reservation += FOOTNOTE_REFERENCE_RESERVATION_TWIPS;
1344
- if (note) {
1345
- reservation += note.blocks.reduce(
1346
- (total, child) => total + estimateCanonicalNoteBlockHeight(child, columnWidth),
1347
- 0,
1348
- );
1349
- }
1540
+ const colonIdx = noteKey.indexOf(":");
1541
+ if (colonIdx === -1) continue;
1542
+ const noteKind = noteKey.slice(0, colonIdx) as "footnote" | "endnote";
1543
+ const noteId = noteKey.slice(colonIdx + 1);
1544
+ // Use measureNoteBody so reservation math and emission share the same path.
1545
+ const { heightTwips } = measureNoteBody(noteKind, noteId, footnotes, columnWidth);
1546
+ reservation += FOOTNOTE_REFERENCE_RESERVATION_TWIPS + heightTwips;
1350
1547
  }
1351
1548
 
1352
1549
  return reservation;