@beyondwork/docx-react-component 1.0.52 → 1.0.54
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 +31 -40
- package/src/api/public-types.ts +67 -7
- package/src/io/chart-preview-resolver.ts +41 -0
- package/src/io/docx-session.ts +217 -23
- 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 +88 -8
- package/src/runtime/document-runtime.ts +182 -9
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +97 -2
- 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 +70 -1
- package/src/runtime/prerender/cache-envelope.ts +30 -0
- package/src/runtime/prerender/customxml-cache.ts +17 -3
- package/src/runtime/prerender/prerender-document.ts +17 -1
- package/src/runtime/render/render-frame-diff.ts +38 -2
- package/src/runtime/render/render-kernel.ts +67 -19
- package/src/runtime/surface-projection.ts +28 -0
- package/src/runtime/table-schema.ts +27 -0
- package/src/runtime/table-style-resolver.ts +51 -0
- package/src/ui/WordReviewEditor.tsx +6 -3
- package/src/ui/editor-runtime-boundary.ts +39 -2
- 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/perf-probe.ts +1 -0
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +54 -0
- package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +107 -3
- package/src/ui-tailwind/index.ts +11 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +2 -2
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +274 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +15 -2
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +15 -2
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +19 -147
- 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/chart-palette-adapter.ts +57 -0
- package/src/ui-tailwind/theme/editor-theme.css +275 -46
- package/src/ui-tailwind/theme/tokens.css +345 -0
- package/src/ui-tailwind/theme/tokens.ts +313 -0
- package/src/ui-tailwind/theme/use-density.ts +60 -0
- 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;
|
|
@@ -1231,15 +1240,48 @@ export function createLayoutFacet(
|
|
|
1231
1240
|
const tableHeightTwips = graph.fragments
|
|
1232
1241
|
.filter((f) => f.blockId === blockId)
|
|
1233
1242
|
.reduce((total, f) => total + f.heightTwips, 0);
|
|
1243
|
+
// Determine if this page holds a continuation slice of the table
|
|
1244
|
+
// (rows after the first slice). Fragment kind "table-slice" with
|
|
1245
|
+
// tableRowRange.from > 0 means the table was split across pages and
|
|
1246
|
+
// this page carries a non-first slice — header rows should repeat.
|
|
1247
|
+
const pageNode = graph.pages.find((p) => p.pageIndex === pageIndex);
|
|
1248
|
+
const sliceFragment = pageNode
|
|
1249
|
+
? graph.fragments.find(
|
|
1250
|
+
(f) => f.blockId === blockId && f.pageId === pageNode.pageId,
|
|
1251
|
+
)
|
|
1252
|
+
: undefined;
|
|
1253
|
+
const isContinuationPage =
|
|
1254
|
+
sliceFragment?.kind === "table-slice" &&
|
|
1255
|
+
(sliceFragment.tableRowRange?.from ?? 0) > 0;
|
|
1256
|
+
|
|
1234
1257
|
return buildTableRenderPlan({
|
|
1235
1258
|
blockId,
|
|
1236
1259
|
pageIndex,
|
|
1237
1260
|
block: tableBlock,
|
|
1238
1261
|
resolved,
|
|
1239
1262
|
tableHeightTwips,
|
|
1263
|
+
isContinuationPage,
|
|
1240
1264
|
});
|
|
1241
1265
|
},
|
|
1242
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
|
+
|
|
1243
1285
|
getDirtyFieldFamilies() {
|
|
1244
1286
|
return engine.getDirtyFieldFamilies();
|
|
1245
1287
|
},
|
|
@@ -1274,11 +1316,36 @@ export function createLayoutFacet(
|
|
|
1274
1316
|
// Internal: graph → public clones
|
|
1275
1317
|
// ---------------------------------------------------------------------------
|
|
1276
1318
|
|
|
1319
|
+
/**
|
|
1320
|
+
* Per-runtime-node cache for `toPublicPageNode`.
|
|
1321
|
+
*
|
|
1322
|
+
* P10 Phase B2. When `spliceGraph` preserves a `RuntimePageNode`
|
|
1323
|
+
* reference across a bounded relayout (Phase D1 tail-convergence
|
|
1324
|
+
* reuse), this cache preserves the derived `PublicPageNode` reference
|
|
1325
|
+
* too — so downstream consumers (React.memo-gated page subtrees in
|
|
1326
|
+
* `TwPageStackChromeLayer`, per-page test hooks) observe stable props
|
|
1327
|
+
* and can skip reconciliation.
|
|
1328
|
+
*
|
|
1329
|
+
* The map is a WeakMap keyed on the runtime node. When a fresh runtime
|
|
1330
|
+
* node replaces a prior one (divergent-tail case), the prior cache
|
|
1331
|
+
* entry is garbage-collected with the prior node; no manual eviction
|
|
1332
|
+
* is required. Structural contract: consumers MUST NOT mutate the
|
|
1333
|
+
* returned `PublicPageNode`, which has always been a read-only clone.
|
|
1334
|
+
*/
|
|
1335
|
+
const publicPageNodeCache = new WeakMap<RuntimePageNode, PublicPageNode>();
|
|
1336
|
+
|
|
1277
1337
|
function toPublicPageNode(
|
|
1278
1338
|
node: RuntimePageNode,
|
|
1279
1339
|
graph: RuntimePageGraph,
|
|
1280
1340
|
): PublicPageNode {
|
|
1281
|
-
|
|
1341
|
+
const cached = publicPageNodeCache.get(node);
|
|
1342
|
+
// Safety guard: reuse only when the runtime node's index still
|
|
1343
|
+
// matches the cached projection. Under normal D1 tail-convergence the
|
|
1344
|
+
// index is preserved, but this belt-and-braces check guarantees we
|
|
1345
|
+
// never hand out a stale clone if a runtime node is ever reused at a
|
|
1346
|
+
// different position by a future splice strategy.
|
|
1347
|
+
if (cached && cached.pageIndex === node.pageIndex) return cached;
|
|
1348
|
+
const built: PublicPageNode = {
|
|
1282
1349
|
pageId: node.pageId,
|
|
1283
1350
|
pageIndex: node.pageIndex,
|
|
1284
1351
|
sectionIndex: node.sectionIndex,
|
|
@@ -1295,7 +1362,9 @@ function toPublicPageNode(
|
|
|
1295
1362
|
lineBoxCount: node.lineBoxes.length,
|
|
1296
1363
|
noteAllocations: node.noteAllocations.map(toPublicNoteAllocation),
|
|
1297
1364
|
};
|
|
1365
|
+
publicPageNodeCache.set(node, built);
|
|
1298
1366
|
void graph; // reserved for future cross-page derivations
|
|
1367
|
+
return built;
|
|
1299
1368
|
}
|
|
1300
1369
|
|
|
1301
1370
|
function toPublicResolvedPageStories(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EditorSurfaceSnapshot } from "../../api/public-types";
|
|
2
2
|
import type { CanonicalDocument } from "../../model/canonical-document.ts";
|
|
3
|
+
import type { CompatibilityReport } from "../../core/state/editor-state.ts";
|
|
3
4
|
import type { RuntimePageGraph } from "../layout/page-graph.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -28,6 +29,17 @@ import type { RuntimePageGraph } from "../layout/page-graph.ts";
|
|
|
28
29
|
* - `canonicalDocumentHash` — sha256 of sorted-keys JSON. Also the 5th
|
|
29
30
|
* input to `deriveCacheKey`, so style/metadata/comment/preservation
|
|
30
31
|
* mutations correctly invalidate the cache.
|
|
32
|
+
*
|
|
33
|
+
* Phase 2 Finale C3 addition (schema v3):
|
|
34
|
+
* - `compatibilityReport?` — pre-computed `CompatibilityReport`. The
|
|
35
|
+
* report is a pure function of `canonicalDocument` (plus the
|
|
36
|
+
* `generatedAt` timestamp, which is pinned to the fixed sentinel
|
|
37
|
+
* `"__cache_normalized__"` at prerender write time so envelopes are
|
|
38
|
+
* byte-identical across two prerender calls). When present on the
|
|
39
|
+
* Plan B short-circuit path, `loadDocxEditorSessionAsync` skips the
|
|
40
|
+
* live `buildCompatibilityReport` call (60–100 ms on extra-large).
|
|
41
|
+
* The field is optional so v3 writers can ship envelopes without it
|
|
42
|
+
* (graceful degradation to the existing live-computation path).
|
|
31
43
|
*/
|
|
32
44
|
export interface CacheEnvelope {
|
|
33
45
|
readonly schemaVersion: number;
|
|
@@ -38,4 +50,22 @@ export interface CacheEnvelope {
|
|
|
38
50
|
readonly graph: RuntimePageGraph;
|
|
39
51
|
readonly surface: EditorSurfaceSnapshot;
|
|
40
52
|
readonly canonicalDocument: CanonicalDocument;
|
|
53
|
+
/**
|
|
54
|
+
* v3+. Optional even at v3 — absence means "compute live on read".
|
|
55
|
+
* See the Phase 2 Finale C3 banner above.
|
|
56
|
+
*/
|
|
57
|
+
readonly compatibilityReport?: CompatibilityReport;
|
|
41
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fixed sentinel used in place of `new Date().toISOString()` when
|
|
62
|
+
* pre-computing `compatibilityReport` for the envelope. Ensures two
|
|
63
|
+
* sequential `prerenderDocument` calls on identical bytes produce
|
|
64
|
+
* byte-identical cache envelopes.
|
|
65
|
+
*
|
|
66
|
+
* Downstream consumers of `compatibilityReport.generatedAt` are
|
|
67
|
+
* diagnostic-only — the field has never been user-visible in the editor
|
|
68
|
+
* UX. Cache-hit paths observe the sentinel; cache-miss paths observe a
|
|
69
|
+
* live ISO8601 timestamp.
|
|
70
|
+
*/
|
|
71
|
+
export const CACHE_NORMALIZED_GENERATED_AT = "__cache_normalized__" as const;
|
|
@@ -194,7 +194,7 @@ function extractInlineCdata(rawXml: string): string | null {
|
|
|
194
194
|
function isValidCacheEnvelope(value: unknown): value is CacheEnvelope {
|
|
195
195
|
if (typeof value !== "object" || value === null) return false;
|
|
196
196
|
const v = value as Record<string, unknown>;
|
|
197
|
-
|
|
197
|
+
const requiredFieldsOk =
|
|
198
198
|
v.schemaVersion === LAYCACHE_SCHEMA_VERSION &&
|
|
199
199
|
v.engineVersion === LAYOUT_ENGINE_VERSION &&
|
|
200
200
|
typeof v.fontFingerprint === "string" &&
|
|
@@ -205,7 +205,21 @@ function isValidCacheEnvelope(value: unknown): value is CacheEnvelope {
|
|
|
205
205
|
typeof v.surface === "object" &&
|
|
206
206
|
v.surface !== null &&
|
|
207
207
|
typeof v.canonicalDocument === "object" &&
|
|
208
|
-
v.canonicalDocument !== null
|
|
209
|
-
);
|
|
208
|
+
v.canonicalDocument !== null;
|
|
209
|
+
if (!requiredFieldsOk) return false;
|
|
210
|
+
// C3 (schema v3): `compatibilityReport` is optional — if present it must
|
|
211
|
+
// be a structured object with the expected shape markers. Envelopes
|
|
212
|
+
// without the field degrade gracefully to live-computation on read.
|
|
213
|
+
if (v.compatibilityReport !== undefined) {
|
|
214
|
+
const cr = v.compatibilityReport as Record<string, unknown> | null;
|
|
215
|
+
if (typeof cr !== "object" || cr === null) return false;
|
|
216
|
+
if (cr.reportVersion !== "compatibility-report/1") return false;
|
|
217
|
+
if (typeof cr.generatedAt !== "string") return false;
|
|
218
|
+
if (typeof cr.blockExport !== "boolean") return false;
|
|
219
|
+
if (!Array.isArray(cr.featureEntries)) return false;
|
|
220
|
+
if (!Array.isArray(cr.warnings)) return false;
|
|
221
|
+
if (!Array.isArray(cr.errors)) return false;
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
210
224
|
}
|
|
211
225
|
|