@beyondwork/docx-react-component 1.0.38 → 1.0.39
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 +41 -31
- package/src/api/public-types.ts +183 -6
- package/src/core/commands/table-structure-commands.ts +31 -2
- package/src/core/commands/text-commands.ts +122 -2
- package/src/io/docx-session.ts +1 -0
- package/src/io/export/serialize-numbering.ts +42 -8
- package/src/io/export/serialize-paragraph-formatting.ts +152 -0
- package/src/io/export/serialize-run-formatting.ts +90 -0
- package/src/io/export/serialize-styles.ts +212 -0
- package/src/io/ooxml/parse-fields.ts +10 -3
- package/src/io/ooxml/parse-numbering.ts +41 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +188 -0
- package/src/io/ooxml/parse-run-formatting.ts +129 -0
- package/src/io/ooxml/parse-styles.ts +31 -0
- package/src/io/ooxml/xml-attr-helpers.ts +60 -0
- package/src/io/ooxml/xml-element.ts +19 -0
- package/src/model/canonical-document.ts +83 -3
- package/src/runtime/collab/event-types.ts +165 -0
- package/src/runtime/collab/index.ts +22 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +93 -0
- package/src/runtime/collab/runtime-collab-sync.ts +273 -0
- package/src/runtime/document-runtime.ts +134 -18
- 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 +69 -2
- package/src/runtime/layout/layout-invalidation.ts +14 -5
- package/src/runtime/layout/page-graph.ts +36 -0
- package/src/runtime/layout/paginate-paragraph-lines.ts +128 -0
- package/src/runtime/layout/paginated-layout-engine.ts +342 -28
- package/src/runtime/layout/project-block-fragments.ts +154 -20
- package/src/runtime/layout/public-facet.ts +40 -1
- package/src/runtime/layout/resolve-page-fields.ts +70 -0
- package/src/runtime/layout/resolve-page-previews.ts +185 -0
- package/src/runtime/layout/resolved-formatting-state.ts +30 -26
- package/src/runtime/layout/table-render-plan.ts +21 -1
- package/src/runtime/numbering-prefix.ts +5 -0
- package/src/runtime/paragraph-style-resolver.ts +194 -0
- package/src/runtime/render/render-kernel.ts +5 -1
- package/src/runtime/resolved-numbering-geometry.ts +9 -1
- package/src/runtime/surface-projection.ts +129 -9
- package/src/runtime/table-schema.ts +11 -0
- package/src/ui/WordReviewEditor.tsx +285 -5
- package/src/ui/editor-command-bag.ts +4 -0
- package/src/ui/editor-runtime-boundary.ts +16 -0
- package/src/ui/editor-shell-view.tsx +4 -0
- package/src/ui/editor-surface-controller.tsx +9 -1
- package/src/ui/headless/chrome-registry.ts +34 -5
- package/src/ui/headless/scoped-chrome-policy.ts +29 -0
- package/src/ui-tailwind/chrome/review-queue-bar.tsx +2 -14
- package/src/ui-tailwind/chrome/role-action-sets.ts +14 -8
- package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +7 -10
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +11 -0
- package/src/ui-tailwind/chrome/tw-table-border-picker.tsx +245 -0
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +101 -21
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +284 -0
- package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +5 -1
- package/src/ui-tailwind/chrome-overlay/index.ts +0 -6
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +27 -17
- package/src/ui-tailwind/editor-surface/page-slice-util.ts +15 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +28 -3
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +389 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +40 -2
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +144 -62
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +179 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +559 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +224 -78
- package/src/ui-tailwind/editor-surface/tw-table-bands.css +61 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +19 -0
- package/src/ui-tailwind/index.ts +1 -5
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +122 -1
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +36 -3
- package/src/ui-tailwind/tw-review-workspace.tsx +132 -54
- package/src/runtime/collab-review-sync.ts +0 -254
- package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +0 -95
- package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
} from "./resolved-formatting-state.ts";
|
|
63
63
|
import { analyzeInvalidation as analyzeInvalidationFn } from "./layout-invalidation.ts";
|
|
64
64
|
import type { LayoutMeasurementProvider } from "./layout-measurement-provider.ts";
|
|
65
|
+
import { paginateParagraphLines } from "./paginate-paragraph-lines.ts";
|
|
65
66
|
|
|
66
67
|
// ---------------------------------------------------------------------------
|
|
67
68
|
// Types
|
|
@@ -81,6 +82,58 @@ export interface PageStackResult {
|
|
|
81
82
|
sections: ResolvedDocumentSection[];
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
/**
|
|
86
|
+
* One slice of a paragraph split across a page boundary.
|
|
87
|
+
*
|
|
88
|
+
* Emitted when intra-paragraph line-box pagination produces a block that
|
|
89
|
+
* renders on multiple pages. The offset range on the page snapshot still
|
|
90
|
+
* spans the whole paragraph (pages are contiguous, non-overlapping); the
|
|
91
|
+
* slice tells consumers which lines of the paragraph render on which page.
|
|
92
|
+
*/
|
|
93
|
+
export interface ParagraphLineSlice {
|
|
94
|
+
/** Global (whole-document) pageIndex this slice renders on. */
|
|
95
|
+
pageIndex: number;
|
|
96
|
+
/** Inclusive-exclusive line range rendered by this slice. */
|
|
97
|
+
lineRange: { from: number; to: number; totalLines: number };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* R3: one slice of a table split at a row boundary across a page break.
|
|
102
|
+
*
|
|
103
|
+
* Emitted when a table's total measured height exceeds the remaining page
|
|
104
|
+
* space and pagination commits to row-level splitting. Consumers render
|
|
105
|
+
* the rows in `rowRange` on the indicated `pageIndex`; when `rowRange.from > 0`
|
|
106
|
+
* and the source table has rows with `isHeader: true`, consumers are
|
|
107
|
+
* expected to prepend those header rows visually so the table header
|
|
108
|
+
* repeats on every continuation page.
|
|
109
|
+
*/
|
|
110
|
+
export interface TableRowSlice {
|
|
111
|
+
/** Global (whole-document) pageIndex this slice renders on. */
|
|
112
|
+
pageIndex: number;
|
|
113
|
+
/** Inclusive-exclusive row range rendered by this slice. */
|
|
114
|
+
rowRange: { from: number; to: number; totalRows: number };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Per-block slice information produced by pagination. `byBlockId` is keyed
|
|
119
|
+
* by `SurfaceBlockSnapshot.blockId`. Absence of a key means the block is
|
|
120
|
+
* unsplit and renders as a single fragment on exactly one page.
|
|
121
|
+
*
|
|
122
|
+
* Paragraph-slice and table-slice keys are disjoint (a block is either a
|
|
123
|
+
* paragraph or a table, never both). Consumers read the appropriate map
|
|
124
|
+
* for the block kind they're projecting.
|
|
125
|
+
*/
|
|
126
|
+
export interface BlockSplits {
|
|
127
|
+
byBlockId: Map<string, ParagraphLineSlice[]>;
|
|
128
|
+
/** R3: table blocks split at row boundaries. */
|
|
129
|
+
tablesByBlockId: Map<string, TableRowSlice[]>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface PageStackResultWithSplits {
|
|
133
|
+
pages: DocumentPageSnapshot[];
|
|
134
|
+
splits: BlockSplits;
|
|
135
|
+
}
|
|
136
|
+
|
|
84
137
|
// ---------------------------------------------------------------------------
|
|
85
138
|
// Facade
|
|
86
139
|
// ---------------------------------------------------------------------------
|
|
@@ -104,7 +157,25 @@ export function buildPageStack(
|
|
|
104
157
|
mainSurface: EditorSurfaceSnapshot,
|
|
105
158
|
measurementProvider?: LayoutMeasurementProvider,
|
|
106
159
|
): DocumentPageSnapshot[] {
|
|
160
|
+
return buildPageStackWithSplits(document, sections, mainSurface, measurementProvider).pages;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Full page-stack computation that also reports per-block slice metadata
|
|
165
|
+
* produced by intra-paragraph line-box pagination.
|
|
166
|
+
*
|
|
167
|
+
* Consumers that need to project blocks into per-page render targets
|
|
168
|
+
* (see `project-block-fragments.ts`) use the `splits` map to emit one
|
|
169
|
+
* `RuntimeBlockFragment` per slice.
|
|
170
|
+
*/
|
|
171
|
+
export function buildPageStackWithSplits(
|
|
172
|
+
document: CanonicalDocumentEnvelope,
|
|
173
|
+
sections: ResolvedDocumentSection[],
|
|
174
|
+
mainSurface: EditorSurfaceSnapshot,
|
|
175
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
176
|
+
): PageStackResultWithSplits {
|
|
107
177
|
const pages: DocumentPageSnapshot[] = [];
|
|
178
|
+
const splitsByBlock = new Map<string, ParagraphLineSlice[]>();
|
|
108
179
|
let globalPageIndex = 0;
|
|
109
180
|
|
|
110
181
|
for (let sectionIdx = 0; sectionIdx < sections.length; sectionIdx++) {
|
|
@@ -157,19 +228,21 @@ export function buildPageStack(
|
|
|
157
228
|
}
|
|
158
229
|
}
|
|
159
230
|
|
|
160
|
-
const
|
|
231
|
+
const paginatedResult = paginateSectionBlocksWithSplits(
|
|
161
232
|
section,
|
|
162
233
|
sectionBlocks,
|
|
163
234
|
layout,
|
|
164
235
|
document.subParts?.footnoteCollection,
|
|
165
236
|
measurementProvider,
|
|
166
237
|
);
|
|
238
|
+
const paginated = paginatedResult.pages;
|
|
167
239
|
|
|
168
240
|
// continuous / nextColumn: merge the first page of this section into the
|
|
169
241
|
// previous section's last page (same visual sheet of paper, different
|
|
170
242
|
// semantic section). The merged page keeps the PREVIOUS section's
|
|
171
243
|
// layout because page geometry cannot change mid-page in OOXML.
|
|
172
244
|
let firstPageHandled = false;
|
|
245
|
+
let mergedIntoGlobalPageIndex: number | null = null;
|
|
173
246
|
if (
|
|
174
247
|
isContinuous &&
|
|
175
248
|
pages.length > 0 &&
|
|
@@ -186,17 +259,40 @@ export function buildPageStack(
|
|
|
186
259
|
firstNewPage.endOffset,
|
|
187
260
|
);
|
|
188
261
|
firstPageHandled = true;
|
|
262
|
+
mergedIntoGlobalPageIndex = previousPage.pageIndex;
|
|
189
263
|
}
|
|
190
264
|
|
|
265
|
+
// Map pageInSection → global pageIndex so we can resolve slice metadata.
|
|
266
|
+
const pageInSectionToGlobal = new Map<number, number>();
|
|
191
267
|
for (let i = 0; i < paginated.length; i += 1) {
|
|
192
|
-
if (firstPageHandled && i === 0) continue;
|
|
193
268
|
const page = paginated[i]!;
|
|
269
|
+
if (firstPageHandled && i === 0) {
|
|
270
|
+
if (mergedIntoGlobalPageIndex !== null) {
|
|
271
|
+
pageInSectionToGlobal.set(page.pageInSection, mergedIntoGlobalPageIndex);
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
pageInSectionToGlobal.set(page.pageInSection, globalPageIndex);
|
|
194
276
|
pages.push({
|
|
195
277
|
...page,
|
|
196
278
|
pageIndex: globalPageIndex,
|
|
197
279
|
});
|
|
198
280
|
globalPageIndex += 1;
|
|
199
281
|
}
|
|
282
|
+
|
|
283
|
+
// Resolve per-section splits to global pageIndex and merge into splitsByBlock.
|
|
284
|
+
for (const [blockId, localSlices] of paginatedResult.splits.byBlockId) {
|
|
285
|
+
const existing = splitsByBlock.get(blockId) ?? [];
|
|
286
|
+
for (const localSlice of localSlices) {
|
|
287
|
+
const globalPageIdx = pageInSectionToGlobal.get(localSlice.pageInSection);
|
|
288
|
+
if (globalPageIdx === undefined) continue; // defensive — paginated might include filler
|
|
289
|
+
existing.push({
|
|
290
|
+
pageIndex: globalPageIdx,
|
|
291
|
+
lineRange: localSlice.lineRange,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
if (existing.length > 0) splitsByBlock.set(blockId, existing);
|
|
295
|
+
}
|
|
200
296
|
}
|
|
201
297
|
|
|
202
298
|
// Guarantee at least one page
|
|
@@ -216,7 +312,11 @@ export function buildPageStack(
|
|
|
216
312
|
}
|
|
217
313
|
|
|
218
314
|
applyWidowControlPass(pages, mainSurface);
|
|
219
|
-
|
|
315
|
+
const tableSplitsByBlock = collectTableRowSlices(mainSurface.blocks, pages);
|
|
316
|
+
return {
|
|
317
|
+
pages,
|
|
318
|
+
splits: { byBlockId: splitsByBlock, tablesByBlockId: tableSplitsByBlock },
|
|
319
|
+
};
|
|
220
320
|
}
|
|
221
321
|
|
|
222
322
|
/**
|
|
@@ -239,18 +339,128 @@ export function buildPageStackFrom(
|
|
|
239
339
|
resumeAt: { startPageIndex: number; startOffset: number },
|
|
240
340
|
measurementProvider?: LayoutMeasurementProvider,
|
|
241
341
|
): DocumentPageSnapshot[] {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
342
|
+
return buildPageStackFromWithSplits(
|
|
343
|
+
document,
|
|
344
|
+
sections,
|
|
345
|
+
mainSurface,
|
|
346
|
+
resumeAt,
|
|
347
|
+
measurementProvider,
|
|
348
|
+
).pages;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Resumable variant that also reports per-block slices (filtered to pages at
|
|
353
|
+
* or after `resumeAt.startPageIndex`).
|
|
354
|
+
*/
|
|
355
|
+
export function buildPageStackFromWithSplits(
|
|
356
|
+
document: CanonicalDocumentEnvelope,
|
|
357
|
+
sections: readonly ResolvedDocumentSection[],
|
|
358
|
+
mainSurface: EditorSurfaceSnapshot,
|
|
359
|
+
resumeAt: { startPageIndex: number; startOffset: number },
|
|
360
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
361
|
+
): PageStackResultWithSplits {
|
|
245
362
|
void resumeAt.startOffset;
|
|
246
|
-
const full =
|
|
363
|
+
const full = buildPageStackWithSplits(
|
|
247
364
|
document,
|
|
248
365
|
sections as ResolvedDocumentSection[],
|
|
249
366
|
mainSurface,
|
|
250
367
|
measurementProvider,
|
|
251
368
|
);
|
|
252
369
|
const startIndex = Math.max(0, resumeAt.startPageIndex);
|
|
253
|
-
|
|
370
|
+
const tailPages = full.pages.slice(startIndex);
|
|
371
|
+
const tailSplits = new Map<string, ParagraphLineSlice[]>();
|
|
372
|
+
for (const [blockId, slices] of full.splits.byBlockId) {
|
|
373
|
+
const tail = slices.filter((s) => s.pageIndex >= startIndex);
|
|
374
|
+
if (tail.length > 0) tailSplits.set(blockId, tail);
|
|
375
|
+
}
|
|
376
|
+
const tailTableSplits = new Map<string, TableRowSlice[]>();
|
|
377
|
+
for (const [blockId, slices] of full.splits.tablesByBlockId) {
|
|
378
|
+
const tail = slices.filter((s) => s.pageIndex >= startIndex);
|
|
379
|
+
if (tail.length > 0) tailTableSplits.set(blockId, tail);
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
pages: tailPages,
|
|
383
|
+
splits: { byBlockId: tailSplits, tablesByBlockId: tailTableSplits },
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// R3: table row-slice collection
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Compute `TableRowSlice[]` entries for table blocks that span multiple pages.
|
|
393
|
+
*
|
|
394
|
+
* Tables are currently placed atomically by `paginateSectionBlocks` — the
|
|
395
|
+
* engine never splits a table mid-row. As a consequence the offset range of
|
|
396
|
+
* every table block falls inside exactly one page's `[startOffset, endOffset)`,
|
|
397
|
+
* and this function returns an empty map.
|
|
398
|
+
*
|
|
399
|
+
* The function is wired so that when row-level table pagination lands (the
|
|
400
|
+
* engine emits `pushPage` mid-table, making `pages[]` contain a page whose
|
|
401
|
+
* `startOffset` lands between two rows of a table), the same walk
|
|
402
|
+
* automatically groups rows by page and emits `TableRowSlice` entries
|
|
403
|
+
* without any further schema change.
|
|
404
|
+
*
|
|
405
|
+
* Row offsets are derived from `row.cells[0].content[0].from` — the first
|
|
406
|
+
* cell's first inner block. Rows whose first cell has no paragraph fall
|
|
407
|
+
* back to the table's own `from` so they group with the table's origin page.
|
|
408
|
+
*/
|
|
409
|
+
function collectTableRowSlices(
|
|
410
|
+
blocks: readonly SurfaceBlockSnapshot[],
|
|
411
|
+
pages: readonly DocumentPageSnapshot[],
|
|
412
|
+
): Map<string, TableRowSlice[]> {
|
|
413
|
+
const result = new Map<string, TableRowSlice[]>();
|
|
414
|
+
if (pages.length === 0) return result;
|
|
415
|
+
|
|
416
|
+
const findPageIndex = (offset: number): number | null => {
|
|
417
|
+
for (const page of pages) {
|
|
418
|
+
if (offset >= page.startOffset && offset < page.endOffset) {
|
|
419
|
+
return page.pageIndex;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const last = pages[pages.length - 1];
|
|
423
|
+
if (last && offset >= last.startOffset && offset <= last.endOffset) {
|
|
424
|
+
return last.pageIndex;
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
for (const block of blocks) {
|
|
430
|
+
if (block.kind !== "table") continue;
|
|
431
|
+
const totalRows = block.rows.length;
|
|
432
|
+
if (totalRows === 0) continue;
|
|
433
|
+
|
|
434
|
+
// Walk rows and assign each to the page containing its start offset.
|
|
435
|
+
const perPageRange = new Map<number, { from: number; to: number }>();
|
|
436
|
+
for (let rowIndex = 0; rowIndex < totalRows; rowIndex += 1) {
|
|
437
|
+
const row = block.rows[rowIndex]!;
|
|
438
|
+
const rowStart = row.cells[0]?.content[0]?.from ?? block.from;
|
|
439
|
+
const pageIndex = findPageIndex(rowStart);
|
|
440
|
+
if (pageIndex === null) continue;
|
|
441
|
+
const existing = perPageRange.get(pageIndex);
|
|
442
|
+
if (existing) {
|
|
443
|
+
existing.to = rowIndex + 1;
|
|
444
|
+
} else {
|
|
445
|
+
perPageRange.set(pageIndex, { from: rowIndex, to: rowIndex + 1 });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Single-page tables produce no slices.
|
|
450
|
+
if (perPageRange.size <= 1) continue;
|
|
451
|
+
|
|
452
|
+
const slices: TableRowSlice[] = [];
|
|
453
|
+
for (const [pageIndex, range] of perPageRange) {
|
|
454
|
+
slices.push({
|
|
455
|
+
pageIndex,
|
|
456
|
+
rowRange: { from: range.from, to: range.to, totalRows },
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
slices.sort((a, b) => a.pageIndex - b.pageIndex);
|
|
460
|
+
result.set(block.blockId, slices);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return result;
|
|
254
464
|
}
|
|
255
465
|
|
|
256
466
|
// ---------------------------------------------------------------------------
|
|
@@ -660,6 +870,26 @@ function collectSectionBlocks(
|
|
|
660
870
|
return blocks.filter((block) => block.to > start && block.from < end);
|
|
661
871
|
}
|
|
662
872
|
|
|
873
|
+
/**
|
|
874
|
+
* Per-section slice key: paragraphs carry `pageInSection` scoped to this
|
|
875
|
+
* section's pagination. The outer `buildPageStackWithSplits` resolves these
|
|
876
|
+
* to global page indices.
|
|
877
|
+
*/
|
|
878
|
+
interface SectionLocalSlice {
|
|
879
|
+
pageInSection: number;
|
|
880
|
+
lineRange: { from: number; to: number; totalLines: number };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
interface SectionPaginationResult {
|
|
884
|
+
pages: Omit<DocumentPageSnapshot, "pageIndex">[];
|
|
885
|
+
splits: { byBlockId: Map<string, SectionLocalSlice[]> };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Backwards-compatible wrapper that returns only the pages. Internal to the
|
|
890
|
+
* module; kept to avoid threading the extra map through ancient call sites
|
|
891
|
+
* that don't need it.
|
|
892
|
+
*/
|
|
663
893
|
function paginateSectionBlocks(
|
|
664
894
|
section: ResolvedDocumentSection,
|
|
665
895
|
blocks: readonly SurfaceBlockSnapshot[],
|
|
@@ -667,19 +897,39 @@ function paginateSectionBlocks(
|
|
|
667
897
|
footnotes: FootnoteCollection | undefined,
|
|
668
898
|
measurementProvider?: LayoutMeasurementProvider,
|
|
669
899
|
): Omit<DocumentPageSnapshot, "pageIndex">[] {
|
|
900
|
+
return paginateSectionBlocksWithSplits(
|
|
901
|
+
section,
|
|
902
|
+
blocks,
|
|
903
|
+
layout,
|
|
904
|
+
footnotes,
|
|
905
|
+
measurementProvider,
|
|
906
|
+
).pages;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function paginateSectionBlocksWithSplits(
|
|
910
|
+
section: ResolvedDocumentSection,
|
|
911
|
+
blocks: readonly SurfaceBlockSnapshot[],
|
|
912
|
+
layout: DocumentPageSnapshot["layout"],
|
|
913
|
+
footnotes: FootnoteCollection | undefined,
|
|
914
|
+
measurementProvider?: LayoutMeasurementProvider,
|
|
915
|
+
): SectionPaginationResult {
|
|
670
916
|
if (blocks.length === 0) {
|
|
671
|
-
return
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
917
|
+
return {
|
|
918
|
+
pages: [
|
|
919
|
+
{
|
|
920
|
+
sectionIndex: section.index,
|
|
921
|
+
pageInSection: 0,
|
|
922
|
+
startOffset: section.start,
|
|
923
|
+
endOffset: section.end,
|
|
924
|
+
layout,
|
|
925
|
+
},
|
|
926
|
+
],
|
|
927
|
+
splits: { byBlockId: new Map() }, // section-local; global map includes tablesByBlockId via collectTableRowSlices
|
|
928
|
+
};
|
|
680
929
|
}
|
|
681
930
|
|
|
682
931
|
const pages: Omit<DocumentPageSnapshot, "pageIndex">[] = [];
|
|
932
|
+
const splitsByBlock = new Map<string, SectionLocalSlice[]>();
|
|
683
933
|
const usableHeight = getUsablePageHeight(layout);
|
|
684
934
|
const columnMetrics = getUsableColumnMetrics(layout);
|
|
685
935
|
const maxColumns = Math.max(1, columnMetrics.length);
|
|
@@ -749,6 +999,66 @@ function paginateSectionBlocks(
|
|
|
749
999
|
reservedNotes.clear();
|
|
750
1000
|
continue;
|
|
751
1001
|
}
|
|
1002
|
+
|
|
1003
|
+
// Intra-paragraph line-box split attempt. When the overflowing block
|
|
1004
|
+
// is a paragraph with enough lines to split, we record a split
|
|
1005
|
+
// metadata entry so renderers can bleed the first `linesOnCurrent`
|
|
1006
|
+
// lines up onto the prior page's body. Offset-wise the paragraph
|
|
1007
|
+
// still lives entirely on the new page — the splits map is the
|
|
1008
|
+
// contract that tells fragment projection to emit a bleed-up slice.
|
|
1009
|
+
//
|
|
1010
|
+
// This keeps page offset ranges contiguous and non-overlapping
|
|
1011
|
+
// (preserving `findPageNodeForOffset` invariants), while still
|
|
1012
|
+
// letting the UI render the paragraph across the boundary.
|
|
1013
|
+
if (
|
|
1014
|
+
block.kind === "paragraph" &&
|
|
1015
|
+
formatting &&
|
|
1016
|
+
!keepLinesActive &&
|
|
1017
|
+
!block.keepNext
|
|
1018
|
+
) {
|
|
1019
|
+
const availableHeight = usableHeight - columnHeight - reservedNoteHeight;
|
|
1020
|
+
const totalLines = measureParagraphLineCount(
|
|
1021
|
+
block,
|
|
1022
|
+
formatting,
|
|
1023
|
+
columnWidth,
|
|
1024
|
+
measurementProvider,
|
|
1025
|
+
);
|
|
1026
|
+
const availableLines =
|
|
1027
|
+
formatting.lineHeight > 0
|
|
1028
|
+
? Math.max(0, Math.floor(availableHeight / formatting.lineHeight))
|
|
1029
|
+
: 0;
|
|
1030
|
+
const splitRule = paginateParagraphLines({
|
|
1031
|
+
totalLines,
|
|
1032
|
+
availableLines,
|
|
1033
|
+
keepLines: keepLinesActive,
|
|
1034
|
+
widowControl: formatting.widowControl,
|
|
1035
|
+
keepNext: Boolean(block.keepNext),
|
|
1036
|
+
isLastBlockOnPage: index === blocks.length - 1,
|
|
1037
|
+
});
|
|
1038
|
+
if (splitRule) {
|
|
1039
|
+
const bleedUpPageInSection = pageInSection;
|
|
1040
|
+
const anchorPageInSection = pageInSection + 1;
|
|
1041
|
+
splitsByBlock.set(block.blockId, [
|
|
1042
|
+
{
|
|
1043
|
+
pageInSection: bleedUpPageInSection,
|
|
1044
|
+
lineRange: {
|
|
1045
|
+
from: 0,
|
|
1046
|
+
to: splitRule.linesOnCurrent,
|
|
1047
|
+
totalLines,
|
|
1048
|
+
},
|
|
1049
|
+
},
|
|
1050
|
+
{
|
|
1051
|
+
pageInSection: anchorPageInSection,
|
|
1052
|
+
lineRange: {
|
|
1053
|
+
from: splitRule.linesOnCurrent,
|
|
1054
|
+
to: totalLines,
|
|
1055
|
+
totalLines,
|
|
1056
|
+
},
|
|
1057
|
+
},
|
|
1058
|
+
]);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
752
1062
|
pushPage(block.from);
|
|
753
1063
|
continue;
|
|
754
1064
|
}
|
|
@@ -797,17 +1107,21 @@ function paginateSectionBlocks(
|
|
|
797
1107
|
}
|
|
798
1108
|
}
|
|
799
1109
|
|
|
800
|
-
return
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1110
|
+
return {
|
|
1111
|
+
pages:
|
|
1112
|
+
pages.length > 0
|
|
1113
|
+
? pages
|
|
1114
|
+
: [
|
|
1115
|
+
{
|
|
1116
|
+
sectionIndex: section.index,
|
|
1117
|
+
pageInSection: 0,
|
|
1118
|
+
startOffset: section.start,
|
|
1119
|
+
endOffset: section.end,
|
|
1120
|
+
layout,
|
|
1121
|
+
},
|
|
1122
|
+
],
|
|
1123
|
+
splits: { byBlockId: splitsByBlock },
|
|
1124
|
+
};
|
|
811
1125
|
}
|
|
812
1126
|
|
|
813
1127
|
function estimateFootnoteReservation(
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Project surface blocks → per-page RuntimeBlockFragments (P4).
|
|
2
|
+
* Project surface blocks → per-page RuntimeBlockFragments (P4 + R3).
|
|
3
3
|
*
|
|
4
4
|
* The pagination engine produces `DocumentPageSnapshot[]` keyed by offset
|
|
5
5
|
* ranges, but for render-kernel consumers to get per-block geometry (and
|
|
6
6
|
* for the chrome to anchor tables, images, etc.) we need one block
|
|
7
|
-
* fragment per top-level surface block, assigned to the page that
|
|
8
|
-
* its offset range.
|
|
7
|
+
* fragment per top-level surface block, assigned to the page that
|
|
8
|
+
* contains its offset range.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* The engine also reports **split metadata** (see `BlockSplits` in
|
|
11
|
+
* `paginated-layout-engine.ts`):
|
|
12
|
+
* - paragraph-slice: one fragment per slice, keyed by `paragraphLineRange`
|
|
13
|
+
* - table-slice: one fragment per row-range slice, keyed by `tableRowRange`
|
|
14
|
+
* - otherwise: one atomic fragment per top-level block (the original P4
|
|
15
|
+
* behavior).
|
|
16
|
+
*
|
|
17
|
+
* Consumers read `fragment.kind` to choose between whole-block rendering
|
|
18
|
+
* and slice-aware rendering (line-range clipping for paragraphs, row-range
|
|
19
|
+
* rendering + repeated header row for tables).
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
22
|
import type {
|
|
@@ -20,46 +25,175 @@ import type {
|
|
|
20
25
|
SurfaceBlockSnapshot,
|
|
21
26
|
} from "../../api/public-types";
|
|
22
27
|
import type { RuntimeBlockFragment } from "./page-graph.ts";
|
|
28
|
+
import type {
|
|
29
|
+
BlockSplits,
|
|
30
|
+
ParagraphLineSlice,
|
|
31
|
+
TableRowSlice,
|
|
32
|
+
} from "./paginated-layout-engine.ts";
|
|
23
33
|
|
|
24
34
|
type FragmentWithoutPageId = Omit<RuntimeBlockFragment, "pageId">;
|
|
25
35
|
|
|
26
36
|
export function projectSurfaceBlocksToPageFragments(
|
|
27
37
|
surface: EditorSurfaceSnapshot,
|
|
28
38
|
pages: readonly DocumentPageSnapshot[],
|
|
39
|
+
splits?: BlockSplits,
|
|
29
40
|
): Map<number, FragmentWithoutPageId[]> {
|
|
30
41
|
const byPage = new Map<number, FragmentWithoutPageId[]>();
|
|
31
42
|
const perPageCounter = new Map<number, number>();
|
|
32
43
|
|
|
44
|
+
const pushFragment = (
|
|
45
|
+
pageIndex: number,
|
|
46
|
+
fragment: FragmentWithoutPageId,
|
|
47
|
+
): void => {
|
|
48
|
+
const existing = byPage.get(pageIndex);
|
|
49
|
+
if (existing) {
|
|
50
|
+
existing.push(fragment);
|
|
51
|
+
} else {
|
|
52
|
+
byPage.set(pageIndex, [fragment]);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const nextOrder = (pageIndex: number): number => {
|
|
57
|
+
const n = perPageCounter.get(pageIndex) ?? 0;
|
|
58
|
+
perPageCounter.set(pageIndex, n + 1);
|
|
59
|
+
return n;
|
|
60
|
+
};
|
|
61
|
+
|
|
33
62
|
for (const block of surface.blocks) {
|
|
63
|
+
// R3: table split across pages — emit one fragment per row slice.
|
|
64
|
+
// Consumers read `tableRowRange` and prepend header rows when from > 0.
|
|
65
|
+
if (block.kind === "table") {
|
|
66
|
+
const tableSliceList = splits?.tablesByBlockId.get(block.blockId);
|
|
67
|
+
if (tableSliceList && tableSliceList.length > 1) {
|
|
68
|
+
emitSlicedTable(
|
|
69
|
+
block,
|
|
70
|
+
tableSliceList,
|
|
71
|
+
(pageIndex, fragment) => {
|
|
72
|
+
pushFragment(pageIndex, {
|
|
73
|
+
...fragment,
|
|
74
|
+
orderInRegion: nextOrder(pageIndex),
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Paragraph split across pages (bleed-up slice metadata): emit one
|
|
83
|
+
// fragment per slice with `paragraphLineRange` set.
|
|
84
|
+
const sliceList = splits?.byBlockId.get(block.blockId);
|
|
85
|
+
if (sliceList && sliceList.length > 1) {
|
|
86
|
+
emitSlicedParagraph(
|
|
87
|
+
block,
|
|
88
|
+
sliceList,
|
|
89
|
+
(pageIndex, fragment) => {
|
|
90
|
+
pushFragment(pageIndex, {
|
|
91
|
+
...fragment,
|
|
92
|
+
orderInRegion: nextOrder(pageIndex),
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Unsplit block: single atomic fragment on the page containing its
|
|
100
|
+
// start offset.
|
|
34
101
|
const pageIndex = findPageIndexForOffset(pages, block.from);
|
|
35
102
|
if (pageIndex === null) continue;
|
|
36
|
-
const orderInRegion = perPageCounter.get(pageIndex) ?? 0;
|
|
37
|
-
perPageCounter.set(pageIndex, orderInRegion + 1);
|
|
38
103
|
|
|
39
104
|
const fragment: FragmentWithoutPageId = {
|
|
40
105
|
fragmentId: `fragment-${block.blockId}`,
|
|
41
106
|
blockId: block.blockId,
|
|
42
|
-
orderInRegion,
|
|
107
|
+
orderInRegion: nextOrder(pageIndex),
|
|
43
108
|
regionKind: "body",
|
|
44
109
|
from: block.from,
|
|
45
110
|
to: block.to,
|
|
46
|
-
// Height is not recomputed here — the pagination engine already
|
|
47
|
-
// measured the block to assign it to this page. Chrome surfaces
|
|
48
|
-
// that need precise heights can consult layout facet measurements.
|
|
49
111
|
heightTwips: estimateBlockHeightFromSpan(block),
|
|
112
|
+
kind: "whole",
|
|
50
113
|
};
|
|
51
114
|
|
|
52
|
-
|
|
53
|
-
if (existing) {
|
|
54
|
-
existing.push(fragment);
|
|
55
|
-
} else {
|
|
56
|
-
byPage.set(pageIndex, [fragment]);
|
|
57
|
-
}
|
|
115
|
+
pushFragment(pageIndex, fragment);
|
|
58
116
|
}
|
|
59
117
|
|
|
60
118
|
return byPage;
|
|
61
119
|
}
|
|
62
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Emit one fragment per slice for a paragraph that pagination split across
|
|
123
|
+
* pages. The source `block` offset range stays intact on every slice; the
|
|
124
|
+
* `paragraphLineRange` identifies which lines the slice renders.
|
|
125
|
+
*/
|
|
126
|
+
function emitSlicedParagraph(
|
|
127
|
+
block: SurfaceBlockSnapshot,
|
|
128
|
+
slices: readonly ParagraphLineSlice[],
|
|
129
|
+
emit: (pageIndex: number, fragment: FragmentWithoutPageId) => void,
|
|
130
|
+
): void {
|
|
131
|
+
for (let i = 0; i < slices.length; i += 1) {
|
|
132
|
+
const slice = slices[i]!;
|
|
133
|
+
const fragment: FragmentWithoutPageId = {
|
|
134
|
+
fragmentId: `fragment-${block.blockId}-slice-${i}`,
|
|
135
|
+
blockId: block.blockId,
|
|
136
|
+
orderInRegion: 0, // re-assigned by caller via nextOrder(pageIndex).
|
|
137
|
+
regionKind: "body",
|
|
138
|
+
from: block.from,
|
|
139
|
+
to: block.to,
|
|
140
|
+
heightTwips: estimateSliceHeightFromLines(slice.lineRange),
|
|
141
|
+
kind: "paragraph-slice",
|
|
142
|
+
paragraphLineRange: slice.lineRange,
|
|
143
|
+
};
|
|
144
|
+
emit(slice.pageIndex, fragment);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function estimateSliceHeightFromLines(lineRange: {
|
|
149
|
+
from: number;
|
|
150
|
+
to: number;
|
|
151
|
+
totalLines: number;
|
|
152
|
+
}): number {
|
|
153
|
+
const lines = Math.max(0, lineRange.to - lineRange.from);
|
|
154
|
+
return lines * 240;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* R3: emit one fragment per row-slice when a table spans multiple pages.
|
|
159
|
+
* The source `block` offset range stays intact on every slice (so selection
|
|
160
|
+
* math and incremental relayout see a single logical block); `tableRowRange`
|
|
161
|
+
* tells downstream consumers which rows to render on this page. When
|
|
162
|
+
* `rowRange.from > 0` and the owning table has `isHeader` rows, consumers
|
|
163
|
+
* prepend those header rows visually so the table's header repeats on
|
|
164
|
+
* every continuation page.
|
|
165
|
+
*/
|
|
166
|
+
function emitSlicedTable(
|
|
167
|
+
block: SurfaceBlockSnapshot,
|
|
168
|
+
slices: readonly TableRowSlice[],
|
|
169
|
+
emit: (pageIndex: number, fragment: FragmentWithoutPageId) => void,
|
|
170
|
+
): void {
|
|
171
|
+
for (let i = 0; i < slices.length; i += 1) {
|
|
172
|
+
const slice = slices[i]!;
|
|
173
|
+
const fragment: FragmentWithoutPageId = {
|
|
174
|
+
fragmentId: `fragment-${block.blockId}-rowslice-${i}`,
|
|
175
|
+
blockId: block.blockId,
|
|
176
|
+
orderInRegion: 0, // re-assigned by caller via nextOrder(pageIndex).
|
|
177
|
+
regionKind: "body",
|
|
178
|
+
from: block.from,
|
|
179
|
+
to: block.to,
|
|
180
|
+
heightTwips: estimateSliceHeightFromRows(slice.rowRange),
|
|
181
|
+
kind: "table-slice",
|
|
182
|
+
tableRowRange: slice.rowRange,
|
|
183
|
+
};
|
|
184
|
+
emit(slice.pageIndex, fragment);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function estimateSliceHeightFromRows(rowRange: {
|
|
189
|
+
from: number;
|
|
190
|
+
to: number;
|
|
191
|
+
totalRows: number;
|
|
192
|
+
}): number {
|
|
193
|
+
const rows = Math.max(0, rowRange.to - rowRange.from);
|
|
194
|
+
return rows * 360; // ~1 line + padding per row, approximate
|
|
195
|
+
}
|
|
196
|
+
|
|
63
197
|
function findPageIndexForOffset(
|
|
64
198
|
pages: readonly DocumentPageSnapshot[],
|
|
65
199
|
offset: number,
|