@beyondwork/docx-react-component 1.0.53 → 1.0.55
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 +1 -1
- package/src/api/public-types.ts +125 -7
- package/src/index.ts +5 -0
- package/src/io/docx-session.ts +27 -3
- package/src/io/normalize/normalize-text.ts +1 -0
- package/src/io/ooxml/parse-field-switches.ts +134 -0
- package/src/io/ooxml/parse-fields.ts +28 -2
- package/src/model/canonical-document.ts +13 -2
- package/src/runtime/chart/chart-model-store.ts +88 -0
- package/src/runtime/chart/chart-snapshot.ts +239 -0
- package/src/runtime/collab/checkpoint-store.ts +1 -1
- package/src/runtime/collab/event-types.ts +4 -0
- package/src/runtime/collab/runtime-collab-sync.ts +1 -2
- package/src/runtime/document-runtime.ts +115 -13
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +58 -1
- package/src/runtime/layout/layout-invalidation.ts +150 -30
- package/src/runtime/layout/page-graph.ts +19 -0
- package/src/runtime/layout/paginated-layout-engine.ts +128 -19
- package/src/runtime/layout/project-block-fragments.ts +27 -0
- package/src/runtime/layout/public-facet.ts +27 -0
- package/src/runtime/page-number-format.ts +207 -0
- package/src/runtime/render/render-frame-diff.ts +38 -2
- package/src/runtime/surface-projection.ts +32 -3
- package/src/ui/WordReviewEditor.tsx +57 -3
- package/src/ui/headless/comment-decoration-model.ts +60 -5
- package/src/ui/headless/revision-decoration-model.ts +94 -6
- package/src/ui/shared/revision-filters.ts +16 -6
- package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
- package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
- package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
- package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
- package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
- package/src/ui-tailwind/chart/render/area.tsx +277 -0
- package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
- package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
- package/src/ui-tailwind/chart/render/combo.tsx +85 -0
- package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
- package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
- package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
- package/src/ui-tailwind/chart/render/line.tsx +363 -0
- package/src/ui-tailwind/chart/render/number-format.ts +120 -16
- package/src/ui-tailwind/chart/render/pie.tsx +275 -0
- package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
- package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
- package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
- package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
- package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
- package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
- package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
- package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
- package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
- package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
- package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
- package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
- package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
- package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
- package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
- package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
- package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
- package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
- package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
- package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
- package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
- package/src/ui-tailwind/editor-surface/chart-node-view.tsx +90 -0
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
- package/src/ui-tailwind/editor-surface/pm-schema.ts +4 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +14 -0
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +2 -1
- package/src/ui-tailwind/index.ts +11 -0
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +52 -2
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +13 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +13 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
- package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
- package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
- package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
- package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
- package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
- package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
- package/src/ui-tailwind/theme/editor-theme.css +249 -22
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
- package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
- package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
- package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
|
@@ -13,19 +13,21 @@
|
|
|
13
13
|
* edit offsets (§analyzeContentEdit).
|
|
14
14
|
* - `section-change`: bounded to the first page of the affected section
|
|
15
15
|
* onward — P10 Phase C (§analyzeSectionChange).
|
|
16
|
-
* - `
|
|
17
|
-
*
|
|
18
|
-
* the
|
|
19
|
-
*
|
|
16
|
+
* - `styles-change` (Slice 5): when `dirtyStyleIds` is supplied, bounded
|
|
17
|
+
* to the first page whose fragments carry a matching `resolvedStyleChainRef`.
|
|
18
|
+
* Fallback to full rebuild when the payload is absent or no match found.
|
|
19
|
+
* - `numbering-change` (Slice 5): when `numberingInstanceId` is supplied,
|
|
20
|
+
* bounded to the first page whose fragments carry a matching
|
|
21
|
+
* `numberingInstanceId`. Fallback to full rebuild when absent or no match.
|
|
20
22
|
*
|
|
21
23
|
* Remaining full-rebuild triggers:
|
|
22
|
-
* - `
|
|
23
|
-
*
|
|
24
|
-
*
|
|
24
|
+
* - `theme-change`: theme token changes cascade through the entire
|
|
25
|
+
* style chain; no fragment-level tightening possible without knowing
|
|
26
|
+
* which blocks reference theme tokens.
|
|
25
27
|
*/
|
|
26
28
|
|
|
27
29
|
import type { LayoutInvalidationReason } from "./paginated-layout-engine.ts";
|
|
28
|
-
import type {
|
|
30
|
+
import type { RuntimeBlockFragment, RuntimePageGraph } from "./page-graph.ts";
|
|
29
31
|
import type { ResolvedDocumentSection } from "../document-layout.ts";
|
|
30
32
|
|
|
31
33
|
// ---------------------------------------------------------------------------
|
|
@@ -92,15 +94,16 @@ export function analyzeInvalidation(
|
|
|
92
94
|
|
|
93
95
|
switch (reason.kind) {
|
|
94
96
|
case "full":
|
|
95
|
-
case "styles-change":
|
|
96
97
|
case "theme-change":
|
|
97
|
-
// These affect the entire document
|
|
98
98
|
return {
|
|
99
99
|
scope: "full",
|
|
100
100
|
requiresFullRecompute: true,
|
|
101
101
|
dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
|
|
102
102
|
};
|
|
103
103
|
|
|
104
|
+
case "styles-change":
|
|
105
|
+
return analyzeStylesChange(reason, graph);
|
|
106
|
+
|
|
104
107
|
case "content-edit":
|
|
105
108
|
return analyzeContentEdit(reason, graph);
|
|
106
109
|
|
|
@@ -108,26 +111,9 @@ export function analyzeInvalidation(
|
|
|
108
111
|
return analyzeSectionChange(reason, graph);
|
|
109
112
|
|
|
110
113
|
case "numbering-change":
|
|
111
|
-
|
|
112
|
-
return {
|
|
113
|
-
scope: "full",
|
|
114
|
-
requiresFullRecompute: true,
|
|
115
|
-
dirtyFieldFamilies: [],
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
return {
|
|
119
|
-
scope: "bounded",
|
|
120
|
-
requiresFullRecompute: false,
|
|
121
|
-
dirtyPageRange: {
|
|
122
|
-
firstPageIndex: 0,
|
|
123
|
-
lastPageIndex: Math.max(0, graph.pages.length - 1),
|
|
124
|
-
},
|
|
125
|
-
dirtySectionRange: null,
|
|
126
|
-
dirtyFieldFamilies: [],
|
|
127
|
-
};
|
|
114
|
+
return analyzeNumberingChange(reason, graph);
|
|
128
115
|
|
|
129
116
|
case "field-refresh":
|
|
130
|
-
// Field refresh doesn't change layout, just field display values
|
|
131
117
|
return {
|
|
132
118
|
scope: "field-only",
|
|
133
119
|
requiresFullRecompute: false,
|
|
@@ -277,12 +263,18 @@ function analyzeSectionChange(
|
|
|
277
263
|
reason.sectionIndex,
|
|
278
264
|
);
|
|
279
265
|
if (firstPageOfSection < 0) {
|
|
266
|
+
// L2: Clamp `from` so the range is never backward when the affected
|
|
267
|
+
// section index exceeds the number of materialized sections (e.g.
|
|
268
|
+
// section-change on a section that hasn't rendered yet, or an empty
|
|
269
|
+
// graph). Without the clamp, {from:5, to:0} is a backward range that
|
|
270
|
+
// confuses consumers iterating [from..to].
|
|
271
|
+
const lastSectionIdx = Math.max(0, graph.sections.length - 1);
|
|
280
272
|
return {
|
|
281
273
|
scope: "full",
|
|
282
274
|
requiresFullRecompute: true,
|
|
283
275
|
dirtySectionRange: {
|
|
284
|
-
from: reason.sectionIndex,
|
|
285
|
-
to:
|
|
276
|
+
from: Math.min(reason.sectionIndex, lastSectionIdx),
|
|
277
|
+
to: lastSectionIdx,
|
|
286
278
|
},
|
|
287
279
|
dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
|
|
288
280
|
};
|
|
@@ -321,3 +313,131 @@ function findFirstPageIndexForSection(
|
|
|
321
313
|
// rendered tail. Caller treats this as a full-rebuild trigger.
|
|
322
314
|
return -1;
|
|
323
315
|
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Slice 5 — narrow `styles-change` invalidation.
|
|
319
|
+
*
|
|
320
|
+
* When `reason.dirtyStyleIds` is present, scan fragments for the first
|
|
321
|
+
* fragment whose `resolvedStyleChainRef` matches any dirty style id.
|
|
322
|
+
* The page containing that fragment is the first that must rebuild.
|
|
323
|
+
*
|
|
324
|
+
* Fallback to full rebuild when `dirtyStyleIds` is absent, empty, or no
|
|
325
|
+
* fragment matches (e.g. graphs built before Slice 5 shipped).
|
|
326
|
+
*/
|
|
327
|
+
function analyzeStylesChange(
|
|
328
|
+
reason: Extract<LayoutInvalidationReason, { kind: "styles-change" }>,
|
|
329
|
+
graph: RuntimePageGraph,
|
|
330
|
+
): InvalidationResult {
|
|
331
|
+
if (!reason.dirtyStyleIds || reason.dirtyStyleIds.length === 0) {
|
|
332
|
+
return {
|
|
333
|
+
scope: "full",
|
|
334
|
+
requiresFullRecompute: true,
|
|
335
|
+
dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const dirtySet = new Set(reason.dirtyStyleIds);
|
|
340
|
+
const firstDirtyPageIndex = findFirstPageIndexByFragmentPredicate(
|
|
341
|
+
graph,
|
|
342
|
+
(f) =>
|
|
343
|
+
f.resolvedStyleChainRef !== undefined &&
|
|
344
|
+
dirtySet.has(f.resolvedStyleChainRef),
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
if (firstDirtyPageIndex < 0) {
|
|
348
|
+
return {
|
|
349
|
+
scope: "full",
|
|
350
|
+
requiresFullRecompute: true,
|
|
351
|
+
dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
scope: "bounded",
|
|
357
|
+
requiresFullRecompute: false,
|
|
358
|
+
dirtyPageRange: {
|
|
359
|
+
firstPageIndex: firstDirtyPageIndex,
|
|
360
|
+
lastPageIndex: Math.max(0, graph.pages.length - 1),
|
|
361
|
+
},
|
|
362
|
+
dirtySectionRange: null,
|
|
363
|
+
dirtyFieldFamilies: [],
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Slice 5 — narrow `numbering-change` invalidation.
|
|
369
|
+
*
|
|
370
|
+
* When `reason.numberingInstanceId` is present, find the first page
|
|
371
|
+
* containing a fragment with matching `numberingInstanceId` and start
|
|
372
|
+
* the rebuild there. Fallback to full rebuild when absent or no match.
|
|
373
|
+
*/
|
|
374
|
+
function analyzeNumberingChange(
|
|
375
|
+
reason: Extract<LayoutInvalidationReason, { kind: "numbering-change" }>,
|
|
376
|
+
graph: RuntimePageGraph,
|
|
377
|
+
): InvalidationResult {
|
|
378
|
+
if (!reason.numberingInstanceId) {
|
|
379
|
+
return {
|
|
380
|
+
scope: "full",
|
|
381
|
+
requiresFullRecompute: true,
|
|
382
|
+
dirtyFieldFamilies: [],
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const instanceId = reason.numberingInstanceId;
|
|
387
|
+
const firstDirtyPageIndex = findFirstPageIndexByFragmentPredicate(
|
|
388
|
+
graph,
|
|
389
|
+
(f) => f.numberingInstanceId === instanceId,
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
if (firstDirtyPageIndex < 0) {
|
|
393
|
+
// No fragment matches (e.g. graph has no fragments yet, or the
|
|
394
|
+
// numberingInstanceId references an instance that hasn't materialized on
|
|
395
|
+
// any page). Per the function's contract — "Fallback to full rebuild
|
|
396
|
+
// when absent or no match" — return a full rebuild so the caller
|
|
397
|
+
// re-paginates from scratch rather than a bounded pass over stale
|
|
398
|
+
// geometry.
|
|
399
|
+
return {
|
|
400
|
+
scope: "full",
|
|
401
|
+
requiresFullRecompute: true,
|
|
402
|
+
dirtyFieldFamilies: [],
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
scope: "bounded",
|
|
408
|
+
requiresFullRecompute: false,
|
|
409
|
+
dirtyPageRange: {
|
|
410
|
+
firstPageIndex: firstDirtyPageIndex,
|
|
411
|
+
lastPageIndex: Math.max(0, graph.pages.length - 1),
|
|
412
|
+
},
|
|
413
|
+
dirtySectionRange: null,
|
|
414
|
+
dirtyFieldFamilies: [],
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Find the pageIndex of the first non-filler page whose fragments include
|
|
420
|
+
* at least one fragment satisfying `predicate`. Returns -1 if none found.
|
|
421
|
+
*/
|
|
422
|
+
function findFirstPageIndexByFragmentPredicate(
|
|
423
|
+
graph: RuntimePageGraph,
|
|
424
|
+
predicate: (f: RuntimeBlockFragment) => boolean,
|
|
425
|
+
): number {
|
|
426
|
+
const pageIdToIndex = new Map<string, number>();
|
|
427
|
+
for (const page of graph.pages) {
|
|
428
|
+
if (!page.isBlankFiller) {
|
|
429
|
+
pageIdToIndex.set(page.pageId, page.pageIndex);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
let firstDirtyPageIndex = -1;
|
|
434
|
+
for (const fragment of graph.fragments) {
|
|
435
|
+
if (!predicate(fragment)) continue;
|
|
436
|
+
const pageIndex = pageIdToIndex.get(fragment.pageId);
|
|
437
|
+
if (pageIndex === undefined) continue;
|
|
438
|
+
if (firstDirtyPageIndex < 0 || pageIndex < firstDirtyPageIndex) {
|
|
439
|
+
firstDirtyPageIndex = pageIndex;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return firstDirtyPageIndex;
|
|
443
|
+
}
|
|
@@ -149,6 +149,18 @@ export interface RuntimeBlockFragment {
|
|
|
149
149
|
to: number;
|
|
150
150
|
totalRows: number;
|
|
151
151
|
};
|
|
152
|
+
/**
|
|
153
|
+
* Slice 5 — opaque style-chain ref derived from the block's `styleId`.
|
|
154
|
+
* Used by `analyzeStylesChange` to bound invalidation to the first page
|
|
155
|
+
* that carries a fragment referencing a dirty style.
|
|
156
|
+
*/
|
|
157
|
+
resolvedStyleChainRef?: string;
|
|
158
|
+
/**
|
|
159
|
+
* Slice 5 — numbering instance id copied from the block's `numbering`
|
|
160
|
+
* field. Used by `analyzeNumberingChange` to bound invalidation to the
|
|
161
|
+
* first page that carries a fragment from the affected list instance.
|
|
162
|
+
*/
|
|
163
|
+
numberingInstanceId?: string;
|
|
152
164
|
}
|
|
153
165
|
|
|
154
166
|
export interface RuntimeLineBox {
|
|
@@ -629,6 +641,13 @@ function pageNodesStructurallyEqual(
|
|
|
629
641
|
for (let i = 0; i < aFoot.length; i += 1) {
|
|
630
642
|
if (!regionFragmentsEqual(aFoot[i]!, bFoot[i]!)) return false;
|
|
631
643
|
}
|
|
644
|
+
// L1: Include line-box and note-allocation counts as structural proxies.
|
|
645
|
+
// Fragment IDs staying stable while line geometry changes (font-metric
|
|
646
|
+
// change, float-wrap) would allow stale node reuse without these guards.
|
|
647
|
+
// Length-only is O(1) and catches the common case; deep baseline
|
|
648
|
+
// comparison is deferred.
|
|
649
|
+
if (a.lineBoxes.length !== b.lineBoxes.length) return false;
|
|
650
|
+
if (a.noteAllocations.length !== b.noteAllocations.length) return false;
|
|
632
651
|
return true;
|
|
633
652
|
}
|
|
634
653
|
|
|
@@ -81,7 +81,7 @@ import {
|
|
|
81
81
|
export type LayoutInvalidationReason =
|
|
82
82
|
| { kind: "content-edit"; from: number; to: number }
|
|
83
83
|
| { kind: "section-change"; sectionIndex: number }
|
|
84
|
-
| { kind: "styles-change" }
|
|
84
|
+
| { kind: "styles-change"; dirtyStyleIds?: readonly string[] }
|
|
85
85
|
| { kind: "theme-change" }
|
|
86
86
|
| { kind: "numbering-change"; numberingInstanceId?: string }
|
|
87
87
|
| { kind: "field-refresh"; family?: string }
|
|
@@ -416,28 +416,137 @@ export function buildPageStackFromWithSplits(
|
|
|
416
416
|
resumeAt: { startPageIndex: number; startOffset: number },
|
|
417
417
|
measurementProvider?: LayoutMeasurementProvider,
|
|
418
418
|
): PageStackResultWithSplits {
|
|
419
|
-
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
419
|
+
const startOffset = resumeAt.startOffset;
|
|
420
|
+
const dirtyPageNumberOffset = Math.max(0, resumeAt.startPageIndex);
|
|
421
|
+
|
|
422
|
+
// Fast path: offset at document start → full paginate is identical.
|
|
423
|
+
if (startOffset <= 0) {
|
|
424
|
+
return buildPageStackWithSplits(
|
|
425
|
+
document,
|
|
426
|
+
sections as ResolvedDocumentSection[],
|
|
427
|
+
mainSurface,
|
|
428
|
+
measurementProvider,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Find the first section that contains or starts after startOffset.
|
|
433
|
+
const firstDirtySectionIdx = (sections as ResolvedDocumentSection[]).findIndex(
|
|
434
|
+
(s) => s.end > startOffset,
|
|
425
435
|
);
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
436
|
+
if (firstDirtySectionIdx < 0) {
|
|
437
|
+
// All sections end before the dirty offset — nothing to re-paginate.
|
|
438
|
+
return buildPageStackWithSplits(
|
|
439
|
+
document,
|
|
440
|
+
sections as ResolvedDocumentSection[],
|
|
441
|
+
mainSurface,
|
|
442
|
+
measurementProvider,
|
|
443
|
+
);
|
|
432
444
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
445
|
+
|
|
446
|
+
// Safety guard: if any block straddles the section boundary (to > startOffset
|
|
447
|
+
// but from < section.start), we cannot safely skip without reconstructing
|
|
448
|
+
// carry-over state — fall back to full paginate to avoid silent correctness loss.
|
|
449
|
+
const dirtySectionStart = (sections as ResolvedDocumentSection[])[firstDirtySectionIdx]!.start;
|
|
450
|
+
const straddling = mainSurface.blocks.some(
|
|
451
|
+
(b) => b.from < dirtySectionStart && b.to > startOffset,
|
|
452
|
+
);
|
|
453
|
+
// When the dirty section is section 0, there is nothing to skip — fall
|
|
454
|
+
// back to the full-build + tail-slice path so page startOffsets are
|
|
455
|
+
// correct (the section-skip path shifts only pageIndex, not startOffset,
|
|
456
|
+
// which would produce phantom pages when spliceGraph prepends page 0).
|
|
457
|
+
if (straddling || firstDirtySectionIdx === 0) {
|
|
458
|
+
const full = buildPageStackWithSplits(
|
|
459
|
+
document,
|
|
460
|
+
sections as ResolvedDocumentSection[],
|
|
461
|
+
mainSurface,
|
|
462
|
+
measurementProvider,
|
|
463
|
+
);
|
|
464
|
+
const tailPages = full.pages.slice(dirtyPageNumberOffset);
|
|
465
|
+
const tailSplits = new Map<string, ParagraphLineSlice[]>();
|
|
466
|
+
for (const [blockId, slices] of full.splits.byBlockId) {
|
|
467
|
+
const tail = slices.filter((s) => s.pageIndex >= dirtyPageNumberOffset);
|
|
468
|
+
if (tail.length > 0) tailSplits.set(blockId, tail);
|
|
469
|
+
}
|
|
470
|
+
const tailTableSplits = new Map<string, TableRowSlice[]>();
|
|
471
|
+
for (const [blockId, slices] of full.splits.tablesByBlockId) {
|
|
472
|
+
const tail = slices.filter((s) => s.pageIndex >= dirtyPageNumberOffset);
|
|
473
|
+
if (tail.length > 0) tailTableSplits.set(blockId, tail);
|
|
474
|
+
}
|
|
475
|
+
return {
|
|
476
|
+
pages: tailPages,
|
|
477
|
+
splits: { byBlockId: tailSplits, tablesByBlockId: tailTableSplits },
|
|
478
|
+
};
|
|
437
479
|
}
|
|
480
|
+
|
|
481
|
+
// Section-level skip: paginate only the dirty sections onward (only
|
|
482
|
+
// reached when firstDirtySectionIdx > 0).
|
|
483
|
+
const dirtySections = (sections as ResolvedDocumentSection[]).slice(firstDirtySectionIdx);
|
|
484
|
+
const dirtyBlockSet = new Set(
|
|
485
|
+
mainSurface.blocks
|
|
486
|
+
.filter((b) => b.from >= dirtySectionStart)
|
|
487
|
+
.map((b) => b.blockId),
|
|
488
|
+
);
|
|
489
|
+
const dirtySurface: EditorSurfaceSnapshot = {
|
|
490
|
+
...mainSurface,
|
|
491
|
+
blocks: mainSurface.blocks.filter((b) => dirtyBlockSet.has(b.blockId)),
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const tailResult = buildPageStackWithSplits(
|
|
495
|
+
document,
|
|
496
|
+
dirtySections,
|
|
497
|
+
dirtySurface,
|
|
498
|
+
measurementProvider,
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
// Shift global page indices on all returned pages and splits so they align
|
|
502
|
+
// with the global page graph (the caller's spliceGraph prepends the head).
|
|
503
|
+
const shiftedPages = tailResult.pages.map((p) => ({
|
|
504
|
+
...p,
|
|
505
|
+
pageIndex: p.pageIndex + dirtyPageNumberOffset,
|
|
506
|
+
}));
|
|
507
|
+
const shiftedSplits: BlockSplits = {
|
|
508
|
+
byBlockId: new Map(
|
|
509
|
+
Array.from(tailResult.splits.byBlockId.entries()).map(([id, slices]) => [
|
|
510
|
+
id,
|
|
511
|
+
slices.map((s) => ({ ...s, pageIndex: s.pageIndex + dirtyPageNumberOffset })),
|
|
512
|
+
]),
|
|
513
|
+
),
|
|
514
|
+
tablesByBlockId: new Map(
|
|
515
|
+
Array.from(tailResult.splits.tablesByBlockId.entries()).map(([id, slices]) => [
|
|
516
|
+
id,
|
|
517
|
+
slices.map((s) => ({ ...s, pageIndex: s.pageIndex + dirtyPageNumberOffset })),
|
|
518
|
+
]),
|
|
519
|
+
),
|
|
520
|
+
};
|
|
521
|
+
// Shift note-allocation page indices if present.
|
|
522
|
+
const shiftedNoteAllocs =
|
|
523
|
+
tailResult.noteAllocationsByPageIndex && tailResult.noteAllocationsByPageIndex.size > 0
|
|
524
|
+
? new Map(
|
|
525
|
+
Array.from(tailResult.noteAllocationsByPageIndex.entries()).map(([pi, allocs]) => [
|
|
526
|
+
pi + dirtyPageNumberOffset,
|
|
527
|
+
allocs,
|
|
528
|
+
]),
|
|
529
|
+
)
|
|
530
|
+
: undefined;
|
|
531
|
+
const shiftedNoteFragments =
|
|
532
|
+
tailResult.noteFragmentsByPageIndex && tailResult.noteFragmentsByPageIndex.size > 0
|
|
533
|
+
? new Map(
|
|
534
|
+
Array.from(tailResult.noteFragmentsByPageIndex.entries()).map(([pi, frags]) => [
|
|
535
|
+
pi + dirtyPageNumberOffset,
|
|
536
|
+
frags,
|
|
537
|
+
]),
|
|
538
|
+
)
|
|
539
|
+
: undefined;
|
|
540
|
+
|
|
438
541
|
return {
|
|
439
|
-
pages:
|
|
440
|
-
splits:
|
|
542
|
+
pages: shiftedPages,
|
|
543
|
+
splits: shiftedSplits,
|
|
544
|
+
...(shiftedNoteAllocs !== undefined
|
|
545
|
+
? { noteAllocationsByPageIndex: shiftedNoteAllocs }
|
|
546
|
+
: {}),
|
|
547
|
+
...(shiftedNoteFragments !== undefined
|
|
548
|
+
? { noteFragmentsByPageIndex: shiftedNoteFragments }
|
|
549
|
+
: {}),
|
|
441
550
|
};
|
|
442
551
|
}
|
|
443
552
|
|
|
@@ -109,6 +109,7 @@ export function projectSurfaceBlocksToPageFragments(
|
|
|
109
109
|
from: block.from,
|
|
110
110
|
to: block.to,
|
|
111
111
|
heightTwips: estimateBlockHeightFromSpan(block),
|
|
112
|
+
...deriveStyleMetadata(block),
|
|
112
113
|
kind: "whole",
|
|
113
114
|
};
|
|
114
115
|
|
|
@@ -138,6 +139,7 @@ function emitSlicedParagraph(
|
|
|
138
139
|
from: block.from,
|
|
139
140
|
to: block.to,
|
|
140
141
|
heightTwips: estimateSliceHeightFromLines(slice.lineRange),
|
|
142
|
+
...deriveStyleMetadata(block),
|
|
141
143
|
kind: "paragraph-slice",
|
|
142
144
|
paragraphLineRange: slice.lineRange,
|
|
143
145
|
};
|
|
@@ -178,6 +180,7 @@ function emitSlicedTable(
|
|
|
178
180
|
from: block.from,
|
|
179
181
|
to: block.to,
|
|
180
182
|
heightTwips: estimateSliceHeightFromRows(slice.rowRange),
|
|
183
|
+
...deriveStyleMetadata(block),
|
|
181
184
|
kind: "table-slice",
|
|
182
185
|
tableRowRange: slice.rowRange,
|
|
183
186
|
};
|
|
@@ -223,3 +226,27 @@ function estimateBlockHeightFromSpan(block: SurfaceBlockSnapshot): number {
|
|
|
223
226
|
const lines = Math.max(1, Math.ceil(span / 80));
|
|
224
227
|
return lines * 240;
|
|
225
228
|
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Slice 5 — extract style metadata from a surface block for use in
|
|
232
|
+
* bounded invalidation. `resolvedStyleChainRef` is the block's styleId
|
|
233
|
+
* (paragraphs and tables only); `numberingInstanceId` comes from the
|
|
234
|
+
* block's list-instance (paragraphs only). Both fields are optional and
|
|
235
|
+
* absent when the block carries no style or numbering data.
|
|
236
|
+
*/
|
|
237
|
+
function deriveStyleMetadata(
|
|
238
|
+
block: SurfaceBlockSnapshot,
|
|
239
|
+
): Pick<FragmentWithoutPageId, "resolvedStyleChainRef" | "numberingInstanceId"> {
|
|
240
|
+
const styleId =
|
|
241
|
+
block.kind === "paragraph" || block.kind === "table"
|
|
242
|
+
? (block.styleId ?? undefined)
|
|
243
|
+
: undefined;
|
|
244
|
+
const numberingInstanceId =
|
|
245
|
+
block.kind === "paragraph"
|
|
246
|
+
? (block.numbering?.numberingInstanceId ?? undefined)
|
|
247
|
+
: undefined;
|
|
248
|
+
return {
|
|
249
|
+
...(styleId !== undefined ? { resolvedStyleChainRef: styleId } : {}),
|
|
250
|
+
...(numberingInstanceId !== undefined ? { numberingInstanceId } : {}),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
@@ -560,6 +560,15 @@ export interface WordReviewEditorLayoutFacet {
|
|
|
560
560
|
pageIndex: number,
|
|
561
561
|
): import("./table-render-plan.ts").TableRenderPlan | null;
|
|
562
562
|
|
|
563
|
+
/**
|
|
564
|
+
* Returns the Y offset (in twips from the top of the page body) where this
|
|
565
|
+
* table fragment starts on the given page. Computed by summing
|
|
566
|
+
* `heightTwips` of all body fragments with `orderInRegion` less than the
|
|
567
|
+
* table fragment's order. Returns `null` when the block is not found on
|
|
568
|
+
* the specified page.
|
|
569
|
+
*/
|
|
570
|
+
getTableBodyYOffsetOnPage(blockId: string, pageIndex: number): number | null;
|
|
571
|
+
|
|
563
572
|
// Fields ---------------------------------------------------------------
|
|
564
573
|
getDirtyFieldFamilies(): readonly string[];
|
|
565
574
|
getFieldDirtinessReport(): PublicFieldDirtinessReport;
|
|
@@ -1255,6 +1264,24 @@ export function createLayoutFacet(
|
|
|
1255
1264
|
});
|
|
1256
1265
|
},
|
|
1257
1266
|
|
|
1267
|
+
getTableBodyYOffsetOnPage(blockId, pageIndex) {
|
|
1268
|
+
const graph = currentGraph();
|
|
1269
|
+
const pageNode = graph.pages.find(
|
|
1270
|
+
(p) => p.pageIndex === pageIndex && !p.isBlankFiller,
|
|
1271
|
+
);
|
|
1272
|
+
if (!pageNode) return null;
|
|
1273
|
+
const bodyFragmentIdSet = new Set(pageNode.regions.body.fragmentIds);
|
|
1274
|
+
const bodyFragments = graph.fragments
|
|
1275
|
+
.filter((f) => bodyFragmentIdSet.has(f.fragmentId))
|
|
1276
|
+
.sort((a, b) => a.orderInRegion - b.orderInRegion);
|
|
1277
|
+
let yOffset = 0;
|
|
1278
|
+
for (const frag of bodyFragments) {
|
|
1279
|
+
if (frag.blockId === blockId) return yOffset;
|
|
1280
|
+
yOffset += frag.heightTwips;
|
|
1281
|
+
}
|
|
1282
|
+
return null;
|
|
1283
|
+
},
|
|
1284
|
+
|
|
1258
1285
|
getDirtyFieldFamilies() {
|
|
1259
1286
|
return engine.getDirtyFieldFamilies();
|
|
1260
1287
|
},
|