@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.
- package/package.json +30 -41
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/public-types.ts +194 -1
- package/src/core/commands/index.ts +33 -8
- package/src/core/search/search-text.ts +15 -2
- package/src/index.ts +13 -0
- package/src/io/docx-session.ts +672 -2
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +83 -0
- package/src/io/ooxml/workflow-payload-validator.ts +97 -1
- package/src/io/ooxml/workflow-payload.ts +172 -1
- package/src/runtime/collab-session.ts +1 -1
- package/src/runtime/document-runtime.ts +364 -36
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +17 -2
- package/src/runtime/layout/paginated-layout-engine.ts +211 -14
- package/src/runtime/layout/public-facet.ts +400 -1
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/surface-projection.ts +10 -5
- package/src/runtime/workflow-markup.ts +71 -16
- package/src/ui/WordReviewEditor.tsx +67 -45
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +110 -11
- 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-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-schema.ts +152 -4
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
- 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 +9 -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/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- 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
|
+
}
|
|
@@ -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
|
|
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
|
|
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) =>
|
|
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;
|