@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.
- package/README.md +17 -0
- package/package.json +5 -4
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/public-types.ts +333 -4
- package/src/core/commands/formatting-commands.ts +7 -1
- package/src/core/commands/index.ts +60 -10
- package/src/core/commands/text-commands.ts +59 -0
- package/src/core/search/search-text.ts +15 -2
- package/src/core/selection/review-anchors.ts +131 -21
- package/src/index.ts +29 -1
- package/src/io/chart-preview-resolver.ts +281 -0
- package/src/io/docx-session.ts +692 -2
- package/src/io/export/build-app-properties-xml.ts +1 -1
- package/src/io/export/serialize-comments.ts +38 -9
- package/src/io/export/twip.ts +1 -1
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +116 -0
- package/src/io/ooxml/parse-comments.ts +0 -33
- package/src/io/ooxml/parse-complex-content.ts +14 -0
- package/src/io/ooxml/parse-main-document.ts +4 -0
- package/src/io/ooxml/workflow-payload-validator.ts +97 -1
- package/src/io/ooxml/workflow-payload.ts +172 -1
- package/src/preservation/opaque-region.ts +5 -0
- package/src/review/store/comment-remapping.ts +2 -2
- package/src/runtime/collab-session.ts +1 -1
- package/src/runtime/document-runtime.ts +661 -42
- package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
- package/src/runtime/edit-dispatch/index.ts +2 -0
- package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/editor-surface/capabilities.ts +411 -0
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +4 -0
- package/src/runtime/layout/layout-engine-instance.ts +63 -2
- package/src/runtime/layout/layout-engine-version.ts +41 -0
- package/src/runtime/layout/paginated-layout-engine.ts +211 -14
- package/src/runtime/layout/public-facet.ts +430 -1
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/prerender/cache-envelope.ts +29 -0
- package/src/runtime/prerender/cache-key.ts +66 -0
- package/src/runtime/prerender/font-fingerprint.ts +17 -0
- package/src/runtime/prerender/graph-canonicalize.ts +121 -0
- package/src/runtime/prerender/indexeddb-cache.ts +184 -0
- package/src/runtime/prerender/prerender-document.ts +145 -0
- package/src/runtime/render/block-fragment-projection.ts +2 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/selection/post-edit-validator.ts +77 -0
- package/src/runtime/surface-projection.ts +45 -7
- package/src/runtime/workflow-markup.ts +71 -16
- package/src/ui/WordReviewEditor.tsx +142 -237
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +115 -12
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui/runtime-shortcut-dispatch.ts +28 -68
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
- package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
- package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +47 -0
- package/src/ui-tailwind/index.ts +5 -1
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- package/src/ui-tailwind/theme/editor-theme.css +47 -14
- 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
|
|
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
|
|
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) =>
|
|
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
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
const
|
|
1343
|
-
reservation
|
|
1344
|
-
|
|
1345
|
-
|
|
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;
|