@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
@@ -0,0 +1,217 @@
1
+ /**
2
+ * editor-state-integration.ts — Schema 1.2 Task D + E helpers.
3
+ *
4
+ * Factored out of document-runtime.ts / docx-session.ts to keep
5
+ * those files focused. Two entry points:
6
+ *
7
+ * - `hydrateEditorStateFromEnvelope`: called right after the runtime
8
+ * is created and the envelope parsed; drives the load-path.
9
+ * - `collectEditorStateForSerialize`: called inside exportDocx before
10
+ * `buildWorkflowPayloadParts`; drives the save-path.
11
+ */
12
+
13
+ import type { EditorStateNamespace } from "../api/editor-state-types.ts";
14
+ import type { EditorStateChannel } from "./editor-state-channel.ts";
15
+ import type {
16
+ EditorStatePayload,
17
+ EditorStatePayloadNamespaceEntry,
18
+ } from "../io/ooxml/workflow-payload.ts";
19
+
20
+ // All namespaces the runtime currently knows about.
21
+ export const ALL_EDITOR_STATE_NAMESPACES: readonly EditorStateNamespace[] = [
22
+ "hostAnnotations",
23
+ "workflowOverlay",
24
+ "workflowMetadata",
25
+ "workItems",
26
+ ] as const;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Load-path: hydrateEditorStateFromEnvelope
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export interface HydrateEditorStateArgs {
33
+ /** The editorState block parsed from the workflow-payload envelope. */
34
+ editorState: EditorStatePayload;
35
+ channel: EditorStateChannel;
36
+ /**
37
+ * Called for each namespace whose blob has been resolved/loaded.
38
+ * Responsible for applying the blob to the appropriate subsystem store
39
+ * (e.g. calling runtime.setHostAnnotationOverlay).
40
+ */
41
+ applyBlob: (namespace: EditorStateNamespace, data: unknown) => void;
42
+ }
43
+
44
+ /**
45
+ * Drives the load-path for schema 1.2 editor-state entries.
46
+ *
47
+ * - Unknown namespaces → preserved opaquely, `unknown_namespace` event.
48
+ * - Inline entries under in-document policy → applied directly.
49
+ * - Keyed entries → resolver called; result applied (or failure handled).
50
+ * - Policy mismatch → `policy_migrated` event.
51
+ *
52
+ * Returns a promise that resolves when all namespaces have been
53
+ * processed. Rejects only when `onResolveError` is `"block"` for a
54
+ * namespace and the resolver fails — the caller should fail the load.
55
+ */
56
+ export async function hydrateEditorStateFromEnvelope(
57
+ args: HydrateEditorStateArgs,
58
+ ): Promise<void> {
59
+ const { editorState, channel, applyBlob } = args;
60
+
61
+ // Record unknown namespaces first — they are preserved opaquely by
62
+ // the payload layer; we emit the warning event AND hand the raw XML
63
+ // to the channel so the next save round-trips it verbatim.
64
+ for (const unknown of editorState.unknownNamespaces ?? []) {
65
+ channel.recordUnknownNamespace(unknown.name, { rawXml: unknown.rawXml });
66
+ }
67
+
68
+ for (const entry of editorState.entries) {
69
+ await hydrateEntry({ entry, channel, applyBlob });
70
+ }
71
+ }
72
+
73
+ async function hydrateEntry(args: {
74
+ entry: EditorStatePayloadNamespaceEntry;
75
+ channel: EditorStateChannel;
76
+ applyBlob: (namespace: EditorStateNamespace, data: unknown) => void;
77
+ }): Promise<void> {
78
+ const { entry, channel, applyBlob } = args;
79
+ const ns = entry.namespace;
80
+ const policyEntry = channel.getPolicyEntry(ns);
81
+
82
+ // Malformed inline JSON: surface as a load failure; don't apply.
83
+ if (entry.malformedInline) {
84
+ channel.recordLoadFailure({
85
+ namespace: ns,
86
+ error: new Error(`Malformed inline JSON for namespace "${ns}"`),
87
+ fallback: policyEntry.onResolveError === "block" ? "empty" : policyEntry.onResolveError,
88
+ });
89
+ return;
90
+ }
91
+
92
+ // Policy-migration detection: compare payload-written location vs
93
+ // current policy location.
94
+ const payloadLocation: string = entry.storageRef
95
+ ? entry.storageRef.location
96
+ : "in-document";
97
+ if (payloadLocation !== policyEntry.location) {
98
+ channel.recordPolicyMigration({
99
+ namespace: ns,
100
+ from: payloadLocation as import("../api/editor-state-types.ts").EditorStateLocation,
101
+ to: policyEntry.location,
102
+ key: entry.storageRef?.entryKey,
103
+ });
104
+ }
105
+
106
+ // Inline path: apply directly when both payload and policy agree on
107
+ // in-document.
108
+ if (entry.inline !== undefined && policyEntry.location === "in-document") {
109
+ applyBlob(ns, entry.inline);
110
+ channel.recordLoaded(ns, {
111
+ namespace: ns,
112
+ schemaVersion: entry.schemaVersion,
113
+ data: entry.inline,
114
+ });
115
+ return;
116
+ }
117
+
118
+ // Keyed path: set the key from the payload, then call the resolver.
119
+ if (entry.storageRef) {
120
+ channel.setKey(ns, entry.storageRef.entryKey);
121
+ // Under keyed policy the resolver wins over any inline blob.
122
+ const result = await channel.resolve(ns, entry.storageRef.entryKey);
123
+ if (result.blob !== null) {
124
+ applyBlob(ns, result.blob.data);
125
+ if (!result.appliedFallback) {
126
+ channel.recordLoaded(ns, result.blob);
127
+ }
128
+ }
129
+ // result.blob === null → failure already handled by channel (event
130
+ // emitted, fallback mode applied). Nothing more to apply.
131
+ return;
132
+ }
133
+
134
+ // Mismatch: payload is inline but policy is keyed. Apply inline as
135
+ // fallback (no key to resolve against).
136
+ if (entry.inline !== undefined) {
137
+ applyBlob(ns, entry.inline);
138
+ channel.recordLoaded(ns, {
139
+ namespace: ns,
140
+ schemaVersion: entry.schemaVersion,
141
+ data: entry.inline,
142
+ });
143
+ }
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Save-path: collectEditorStateForSerialize
148
+ // ---------------------------------------------------------------------------
149
+
150
+ export interface CollectEditorStateArgs {
151
+ channel: EditorStateChannel;
152
+ /**
153
+ * Returns the current in-memory blob for a namespace, or null if
154
+ * the namespace has no data to persist.
155
+ */
156
+ getNamespaceData: (ns: EditorStateNamespace) => { schemaVersion: string; data: unknown } | null;
157
+ }
158
+
159
+ /**
160
+ * Builds the `EditorStatePayload` for the serializer.
161
+ *
162
+ * 1. Flushes any pending debounced persists so the docx captures
163
+ * the last-known-good state for rowstore namespaces.
164
+ * 2. For each namespace with data: emits inline or storageRef per policy.
165
+ * 3. Returns undefined when no namespaces have data — the serializer
166
+ * then omits `<bw:editorState>` entirely (downgrade to 1.1/1.0).
167
+ */
168
+ export async function collectEditorStateForSerialize(
169
+ args: CollectEditorStateArgs,
170
+ ): Promise<EditorStatePayload | undefined> {
171
+ const { channel, getNamespaceData } = args;
172
+
173
+ // Flush pending debounced persists before serialize resolves.
174
+ await channel.flush();
175
+
176
+ const entries: EditorStatePayloadNamespaceEntry[] = [];
177
+
178
+ for (const ns of ALL_EDITOR_STATE_NAMESPACES) {
179
+ const current = getNamespaceData(ns);
180
+ if (!current) continue;
181
+
182
+ const policyEntry = channel.getPolicyEntry(ns);
183
+
184
+ if (policyEntry.location === "in-document") {
185
+ entries.push({
186
+ namespace: ns,
187
+ schemaVersion: current.schemaVersion,
188
+ inline: current.data,
189
+ });
190
+ } else {
191
+ const key = channel.getKey(ns);
192
+ if (!key) continue; // Keyed policy without a key — can't serialize ref.
193
+ entries.push({
194
+ namespace: ns,
195
+ schemaVersion: current.schemaVersion,
196
+ storageRef: {
197
+ location: policyEntry.location as Exclude<
198
+ import("../api/editor-state-types.ts").EditorStateLocation,
199
+ "in-document"
200
+ >,
201
+ entryKey: key,
202
+ },
203
+ });
204
+ }
205
+ }
206
+
207
+ const unknownEntries = channel.getUnknownEntries();
208
+ const unknownNamespaces = unknownEntries
209
+ .filter((u): u is typeof u & { rawXml: string } => typeof u.rawXml === "string")
210
+ .map((u) => ({ name: u.name, rawXml: u.rawXml }));
211
+
212
+ if (entries.length === 0 && unknownNamespaces.length === 0) return undefined;
213
+ return {
214
+ entries,
215
+ ...(unknownNamespaces.length > 0 ? { unknownNamespaces } : {}),
216
+ };
217
+ }
@@ -172,6 +172,8 @@ export {
172
172
  type PublicPageNode,
173
173
  type PublicPageRegions,
174
174
  type PublicPageRegion,
175
+ type PublicRegionBlock,
176
+ type PublicRegionKind,
175
177
  type PublicBlockFragment,
176
178
  type PublicLineBox,
177
179
  type PublicNoteAllocation,
@@ -36,6 +36,8 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
36
36
  getLineBoxes: () => [],
37
37
  getLineBoxesForRegion: () => [],
38
38
  getStoryRegionsOnPage: () => [],
39
+ getStoryBlocksForRegion: () => [],
40
+ getDocumentEndnoteBlocks: () => [],
39
41
  getFragmentsForPage: () => [],
40
42
  getPageFormatCatalog: () => PAGE_FORMAT_CATALOG,
41
43
  getActivePageFormat: () => null,
@@ -296,16 +296,24 @@ export function createLayoutEngine(
296
296
  );
297
297
  const pages = pageStack.pages;
298
298
  const stories = resolvePageStories(pages);
299
- const fragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
299
+ const bodyFragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
300
300
  mainSurface,
301
301
  pages,
302
302
  pageStack.splits,
303
303
  );
304
+ // P8.1b — merge per-note fragments (regionKind: "footnote-area") into the
305
+ // main fragments map so buildPageGraph sees them alongside body fragments.
306
+ const fragmentsByPageIndex = new Map(bodyFragmentsByPageIndex);
307
+ for (const [pageIndex, noteFragments] of (pageStack.noteFragmentsByPageIndex ?? new Map())) {
308
+ const existing = fragmentsByPageIndex.get(pageIndex) ?? [];
309
+ fragmentsByPageIndex.set(pageIndex, [...existing, ...noteFragments]);
310
+ }
304
311
  const graph = buildPageGraph({
305
312
  pages,
306
313
  sections,
307
314
  stories,
308
315
  fragmentsByPageIndex,
316
+ noteAllocationsByPageIndex: pageStack.noteAllocationsByPageIndex,
309
317
  });
310
318
 
311
319
  // Field dirtiness diff from previous graph
@@ -413,16 +421,23 @@ export function createLayoutEngine(
413
421
  const freshStories = resolvePageStories(freshSnapshots);
414
422
  // Project fragments for the fresh tail pages, threading paragraph
415
423
  // line-range splits produced by intra-paragraph pagination.
416
- const freshFragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
424
+ const freshBodyFragmentsByPageIndex = projectSurfaceBlocksToPageFragments(
417
425
  mainSurface,
418
426
  freshSnapshots,
419
427
  freshResult.splits,
420
428
  );
429
+ // P8.1b — merge per-note fragments into the fresh fragments map.
430
+ const freshFragmentsByPageIndex = new Map(freshBodyFragmentsByPageIndex);
431
+ for (const [pageIndex, noteFragments] of (freshResult.noteFragmentsByPageIndex ?? new Map())) {
432
+ const existing = freshFragmentsByPageIndex.get(pageIndex) ?? [];
433
+ freshFragmentsByPageIndex.set(pageIndex, [...existing, ...noteFragments]);
434
+ }
421
435
  const freshGraph = buildPageGraph({
422
436
  pages: freshSnapshots,
423
437
  sections,
424
438
  stories: freshStories,
425
439
  fragmentsByPageIndex: freshFragmentsByPageIndex,
440
+ noteAllocationsByPageIndex: freshResult.noteAllocationsByPageIndex,
426
441
  });
427
442
  const freshNodes = freshGraph.pages;
428
443
 
@@ -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;