@beyondwork/docx-react-component 1.0.83 → 1.0.85
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/internal/build-ref-projections.ts +3 -0
- package/src/api/public-types.ts +86 -4
- package/src/api/v3/_runtime-handle.ts +15 -0
- package/src/api/v3/runtime/content.ts +148 -1
- package/src/api/v3/runtime/formatting.ts +41 -0
- package/src/api/v3/runtime/review.ts +98 -0
- package/src/api/v3/runtime/workflow.ts +154 -6
- package/src/core/commands/index.ts +81 -25
- package/src/core/state/editor-state.ts +15 -0
- package/src/io/export/serialize-main-document.ts +72 -6
- package/src/io/ooxml/header-footer-reference.ts +38 -0
- package/src/io/ooxml/parse-headers-footers.ts +11 -23
- package/src/io/ooxml/parse-main-document.ts +7 -10
- package/src/io/ooxml/workflow-payload-validator.ts +24 -0
- package/src/io/ooxml/workflow-payload.ts +12 -0
- package/src/model/canonical-document.ts +9 -0
- package/src/model/review/comment-types.ts +2 -0
- package/src/runtime/document-runtime.ts +718 -68
- package/src/runtime/formatting/field/resolver.ts +73 -8
- package/src/runtime/layout/layout-engine-version.ts +31 -12
- package/src/runtime/layout/paginated-layout-engine.ts +18 -11
- package/src/runtime/layout/public-facet.ts +119 -16
- package/src/runtime/layout/resolve-page-fields.ts +68 -6
- package/src/runtime/layout/resolve-page-previews.ts +1 -1
- package/src/runtime/scopes/_scope-dependencies.ts +1 -0
- package/src/runtime/scopes/action-validation.ts +54 -45
- package/src/runtime/scopes/workflow-overlap.ts +41 -9
- package/src/runtime/suggestions-snapshot.ts +24 -0
- package/src/runtime/surface-projection.ts +59 -2
- package/src/runtime/workflow/coordinator.ts +66 -14
- package/src/runtime/workflow/scope-writer.ts +83 -5
- package/src/shell/ref-commands.ts +3 -354
- package/src/shell/session-bootstrap.ts +10 -0
- package/src/ui/WordReviewEditor.tsx +99 -9
- package/src/ui/editor-command-bag.ts +3 -1
- package/src/ui/headless/revision-decoration-model.ts +13 -0
- package/src/ui/headless/selection-tool-types.ts +2 -0
- package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +7 -3
- package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +175 -25
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -1
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +12 -0
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +18 -30
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +1 -1
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +1 -1
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +20 -11
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +9 -4
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +12 -7
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +29 -10
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +1 -1
- package/src/ui-tailwind/review-workspace/types.ts +3 -2
- package/src/ui-tailwind/review-workspace/use-page-markers.ts +11 -1
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +2 -1
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +2 -1
- package/src/ui-tailwind/tw-review-workspace.tsx +18 -2
|
@@ -13,7 +13,9 @@ import type {
|
|
|
13
13
|
ParagraphNode,
|
|
14
14
|
StylesCatalog,
|
|
15
15
|
} from "../../../model/canonical-document.ts";
|
|
16
|
+
import { resolvePageFieldDisplayText } from "../../layout/resolve-page-fields.ts";
|
|
16
17
|
import type { RuntimePageGraph, RuntimePageNode } from "../../layout/page-graph.ts";
|
|
18
|
+
import { formatPageNumber } from "./page-number-format.ts";
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* Layer-03 alias for the layout module's `RuntimePageGraph`. Exists so
|
|
@@ -80,7 +82,11 @@ export function createFieldResolver(input: FieldResolverInput): FieldResolver {
|
|
|
80
82
|
return { displayText: "", refreshStatus: "unresolvable" };
|
|
81
83
|
}
|
|
82
84
|
return {
|
|
83
|
-
displayText:
|
|
85
|
+
displayText: resolvePageFieldDisplayText(
|
|
86
|
+
"PAGE",
|
|
87
|
+
entry.displayText ?? "",
|
|
88
|
+
{ page, graph: pageGraph },
|
|
89
|
+
),
|
|
84
90
|
refreshStatus: "current",
|
|
85
91
|
};
|
|
86
92
|
}
|
|
@@ -90,7 +96,34 @@ export function createFieldResolver(input: FieldResolverInput): FieldResolver {
|
|
|
90
96
|
return { displayText: "", refreshStatus: "unresolvable" };
|
|
91
97
|
}
|
|
92
98
|
return {
|
|
93
|
-
displayText:
|
|
99
|
+
displayText: resolvePageFieldDisplayText(
|
|
100
|
+
"NUMPAGES",
|
|
101
|
+
entry.displayText ?? "",
|
|
102
|
+
{ page: pageGraph.pages[activePageIndex] ?? pageGraph.pages[0]!, graph: pageGraph },
|
|
103
|
+
),
|
|
104
|
+
refreshStatus: "current",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
case "SECTIONPAGES": {
|
|
109
|
+
const page = pageGraph.pages[activePageIndex];
|
|
110
|
+
if (!page) {
|
|
111
|
+
return { displayText: "", refreshStatus: "unresolvable" };
|
|
112
|
+
}
|
|
113
|
+
const sectionPageCount = pageGraph.pages.filter(
|
|
114
|
+
(candidate) =>
|
|
115
|
+
!candidate.isBlankFiller &&
|
|
116
|
+
candidate.sectionIndex === page.sectionIndex,
|
|
117
|
+
).length;
|
|
118
|
+
if (sectionPageCount === 0) {
|
|
119
|
+
return { displayText: "", refreshStatus: "unresolvable" };
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
displayText: resolvePageFieldDisplayText(
|
|
123
|
+
"SECTIONPAGES",
|
|
124
|
+
entry.displayText ?? "",
|
|
125
|
+
{ page, graph: pageGraph },
|
|
126
|
+
),
|
|
94
127
|
refreshStatus: "current",
|
|
95
128
|
};
|
|
96
129
|
}
|
|
@@ -125,7 +158,7 @@ export function createFieldResolver(input: FieldResolverInput): FieldResolver {
|
|
|
125
158
|
return { displayText: "", refreshStatus: "unresolvable" };
|
|
126
159
|
}
|
|
127
160
|
return {
|
|
128
|
-
displayText:
|
|
161
|
+
displayText: resolvePageGraphFieldText("PAGE", page, pageGraph),
|
|
129
162
|
refreshStatus: "current",
|
|
130
163
|
...(entry.switches?.hyperlink ? { asHyperlink: true as const } : {}),
|
|
131
164
|
};
|
|
@@ -182,18 +215,16 @@ export function createFieldResolver(input: FieldResolverInput): FieldResolver {
|
|
|
182
215
|
return undefined;
|
|
183
216
|
|
|
184
217
|
case "NOTEREF":
|
|
185
|
-
|
|
186
|
-
// These families are classified as SupportedFieldFamily (see
|
|
218
|
+
// NOTEREF is classified as SupportedFieldFamily (see
|
|
187
219
|
// `src/model/canonical-document.ts::SupportedFieldFamily`) but
|
|
188
|
-
//
|
|
220
|
+
// is NOT implemented in the resolver yet. Returning
|
|
189
221
|
// `refreshStatus: "unresolvable"` is the honest answer — the
|
|
190
222
|
// caller knows the family is recognized but the runtime has
|
|
191
223
|
// no resolution path. This distinguishes them from TOC
|
|
192
224
|
// (intentional `undefined` = preserve existing display) and
|
|
193
225
|
// from PreserveOnlyFieldFamily (parse-only, no refresh slot).
|
|
194
226
|
// Tracked in L03 audit 2026-04-22 as Task-4 follow-up: implement
|
|
195
|
-
// NOTEREF via footnote/endnote anchor lookup
|
|
196
|
-
// pageGraph section-scoped page-count walk.
|
|
227
|
+
// NOTEREF via footnote/endnote anchor lookup.
|
|
197
228
|
return { displayText: "", refreshStatus: "unresolvable" };
|
|
198
229
|
|
|
199
230
|
default:
|
|
@@ -248,6 +279,40 @@ function findPageForOffset(
|
|
|
248
279
|
return result;
|
|
249
280
|
}
|
|
250
281
|
|
|
282
|
+
function resolvePageGraphFieldText(
|
|
283
|
+
family: "PAGE" | "NUMPAGES" | "SECTIONPAGES",
|
|
284
|
+
page: RuntimePageNode,
|
|
285
|
+
graph: RuntimePageGraph,
|
|
286
|
+
): string {
|
|
287
|
+
switch (family) {
|
|
288
|
+
case "PAGE":
|
|
289
|
+
return formatPageNumber(
|
|
290
|
+
page.stories.displayPageNumber,
|
|
291
|
+
page.layout?.pageNumbering?.format,
|
|
292
|
+
);
|
|
293
|
+
case "NUMPAGES":
|
|
294
|
+
return String(graph.contentPageCount);
|
|
295
|
+
case "SECTIONPAGES":
|
|
296
|
+
return formatPageNumber(
|
|
297
|
+
countContentPagesInSection(graph, page.sectionIndex),
|
|
298
|
+
page.layout?.pageNumbering?.format,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function countContentPagesInSection(
|
|
304
|
+
graph: RuntimePageGraph,
|
|
305
|
+
sectionIndex: number,
|
|
306
|
+
): number {
|
|
307
|
+
let count = 0;
|
|
308
|
+
for (const page of graph.pages) {
|
|
309
|
+
if (page.sectionIndex === sectionIndex && !page.isBlankFiller) {
|
|
310
|
+
count += 1;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return count;
|
|
314
|
+
}
|
|
315
|
+
|
|
251
316
|
/**
|
|
252
317
|
* Walk paragraphs in document order and invoke `visit` for each one.
|
|
253
318
|
* Tables are traversed via their `rows` arrays, cells via their `children`.
|
|
@@ -948,15 +948,7 @@
|
|
|
948
948
|
* invalidate because any future document with a real body framePr
|
|
949
949
|
* paginates differently.
|
|
950
950
|
*
|
|
951
|
-
* 56 —
|
|
952
|
-
* `WordReviewEditorLayoutFacet` gained `setVisibleBlockRanges` for
|
|
953
|
-
* multi-range viewport realization (paired with coord-07 §2.9
|
|
954
|
-
* `runtime.viewport.subscribe`). The contract widened but the
|
|
955
|
-
* underlying layout algorithm is unchanged — persisted envelopes
|
|
956
|
-
* remain shape-compatible. Bump is defensive so any consumer that
|
|
957
|
-
* keyed on the facet contract refreshes the cache.
|
|
958
|
-
*
|
|
959
|
-
* 57 — viewport-cull flicker fix. The pre-v57 PM placeholder emitted
|
|
951
|
+
* 56 — viewport-cull flicker fix. The pre-v56 PM placeholder emitted
|
|
960
952
|
* for `placeholder-culled` opaque blocks rendered at
|
|
961
953
|
* `min-height: 20px` regardless of the real block's visual height
|
|
962
954
|
* (`src/ui-tailwind/editor-surface/pm-schema.ts`), because neither
|
|
@@ -978,11 +970,38 @@
|
|
|
978
970
|
* fallback when the attr is set.
|
|
979
971
|
*
|
|
980
972
|
* Pagination itself is untouched — this is purely a render-surface
|
|
981
|
-
* fix. Cache envelopes from
|
|
973
|
+
* fix. Cache envelopes from v55 invalidate because the exposed
|
|
982
974
|
* facet surface grew one public method; any consumer relying on
|
|
983
|
-
* the prior interface shape re-derives its cache key under
|
|
975
|
+
* the prior interface shape re-derives its cache key under v56.
|
|
976
|
+
*
|
|
977
|
+
* 57 — canvas pagination seam polish. The canvas-posture page-break
|
|
978
|
+
* widget no longer renders the `N / M` pagination marker as a rounded
|
|
979
|
+
* bubble/card. It now paints only the dotted seam line plus plain
|
|
980
|
+
* page-number text (`data-kind="canvas-seam-page-number"`), removing
|
|
981
|
+
* the old background, border, radius, and shadow from the widget DOM.
|
|
982
|
+
* Pagination geometry is unchanged, but cached render-frame DOM
|
|
983
|
+
* snapshots from v56 must invalidate because the widget shape changed.
|
|
984
|
+
*
|
|
985
|
+
* 58 — pagination honors style-derived paragraph flow flags where they
|
|
986
|
+
* are strong enough to affect page assignment. The paginated layout
|
|
987
|
+
* pass promotes cascaded `keepNext` only when the paragraph is also
|
|
988
|
+
* resolved with `keepLines` (direct `keepNext` is unchanged). This
|
|
989
|
+
* matches the built-in heading pattern used by the SOW redline pages
|
|
990
|
+
* without letting keep-next-only legal template headings or custom
|
|
991
|
+
* style page-break hints broadly over-paginate the CCEP corpus. Cache
|
|
992
|
+
* envelopes from v57 invalidate because page assignment can change for
|
|
993
|
+
* style-driven docs.
|
|
994
|
+
*
|
|
995
|
+
* 59 — page-field resolver reads from `public-facet`'s page-graph
|
|
996
|
+
* truth (`a9969e97`). `public-facet.ts` gained
|
|
997
|
+
* `getStoryBlocksForRegion(regionIndex): ReadonlyArray<BlockId>`
|
|
998
|
+
* and `resolve-page-fields.ts` was rewritten to consume it instead
|
|
999
|
+
* of the older cached resolver state that could go stale after
|
|
1000
|
+
* layout changes. The facet contract widened by one public method —
|
|
1001
|
+
* persisted cache envelopes from v58 must re-derive their page-field
|
|
1002
|
+
* projections.
|
|
984
1003
|
*/
|
|
985
|
-
export const LAYOUT_ENGINE_VERSION =
|
|
1004
|
+
export const LAYOUT_ENGINE_VERSION = 59 as const;
|
|
986
1005
|
|
|
987
1006
|
/**
|
|
988
1007
|
* Serialization schema version for the LayCache payload (the cache envelope
|
|
@@ -1637,36 +1637,43 @@ export function paginateSectionBlocksWithSplits(
|
|
|
1637
1637
|
index,
|
|
1638
1638
|
);
|
|
1639
1639
|
|
|
1640
|
+
const formatting = block.kind === "paragraph"
|
|
1641
|
+
? resolveBlockFormatting(block, defaultTabInterval, themeFonts)
|
|
1642
|
+
: null;
|
|
1643
|
+
const keepLinesActive = formatting?.keepLines ?? false;
|
|
1644
|
+
const keepNextActive = block.kind === "paragraph"
|
|
1645
|
+
? Boolean(block.keepNext ?? (block.resolvedParagraphFormatting?.keepNext && keepLinesActive))
|
|
1646
|
+
: false;
|
|
1647
|
+
const pageBreakBeforeActive = block.kind === "paragraph"
|
|
1648
|
+
? Boolean(block.pageBreakBefore)
|
|
1649
|
+
: false;
|
|
1650
|
+
|
|
1640
1651
|
// keepNext: this paragraph must stay with the next one on the same page
|
|
1652
|
+
const nextBlock = blocks[index + 1];
|
|
1641
1653
|
const keepWithNextHeight =
|
|
1642
|
-
block.kind === "paragraph" &&
|
|
1654
|
+
block.kind === "paragraph" && keepNextActive && nextBlock
|
|
1643
1655
|
? baseHeight +
|
|
1644
1656
|
applyContextualSpacingAdjustment(
|
|
1645
1657
|
measureBlockHeight(
|
|
1646
|
-
|
|
1658
|
+
nextBlock,
|
|
1647
1659
|
columnWidth,
|
|
1648
1660
|
measurementProvider,
|
|
1649
1661
|
cache,
|
|
1650
1662
|
defaultTabInterval,
|
|
1651
1663
|
themeFonts,
|
|
1652
1664
|
),
|
|
1653
|
-
|
|
1665
|
+
nextBlock,
|
|
1654
1666
|
index + 1,
|
|
1655
1667
|
)
|
|
1656
1668
|
: baseHeight;
|
|
1657
1669
|
|
|
1658
1670
|
// keepLines: the entire paragraph must fit on one page.
|
|
1659
1671
|
// If it doesn't fit and there's already content on this page, break before it.
|
|
1660
|
-
const formatting = block.kind === "paragraph"
|
|
1661
|
-
? resolveBlockFormatting(block, defaultTabInterval, themeFonts)
|
|
1662
|
-
: null;
|
|
1663
|
-
const keepLinesActive = formatting?.keepLines ?? false;
|
|
1664
|
-
|
|
1665
1672
|
const noteHeight = estimateFootnoteReservation(block, footnotes, columnWidth, reservedNotes);
|
|
1666
1673
|
const projectedHeight = columnHeight + keepWithNextHeight + reservedNoteHeight + noteHeight;
|
|
1667
1674
|
|
|
1668
1675
|
// pageBreakBefore
|
|
1669
|
-
if (block.kind === "paragraph" &&
|
|
1676
|
+
if (block.kind === "paragraph" && pageBreakBeforeActive && pageStart < block.from) {
|
|
1670
1677
|
pushPage(block.from);
|
|
1671
1678
|
continue;
|
|
1672
1679
|
}
|
|
@@ -1785,7 +1792,7 @@ export function paginateSectionBlocksWithSplits(
|
|
|
1785
1792
|
block.kind === "paragraph" &&
|
|
1786
1793
|
formatting &&
|
|
1787
1794
|
!keepLinesActive &&
|
|
1788
|
-
!
|
|
1795
|
+
!keepNextActive
|
|
1789
1796
|
) {
|
|
1790
1797
|
const availableHeight = usableHeight - columnHeight - reservedNoteHeight;
|
|
1791
1798
|
const cachedLineCount = cache?.getLineCount(block, columnWidth);
|
|
@@ -1809,7 +1816,7 @@ export function paginateSectionBlocksWithSplits(
|
|
|
1809
1816
|
availableLines,
|
|
1810
1817
|
keepLines: keepLinesActive,
|
|
1811
1818
|
widowControl: formatting.widowControl,
|
|
1812
|
-
keepNext:
|
|
1819
|
+
keepNext: keepNextActive,
|
|
1813
1820
|
isLastBlockOnPage: index === blocks.length - 1,
|
|
1814
1821
|
});
|
|
1815
1822
|
if (splitRule) {
|
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
import { createFormattingContext } from "../formatting/formatting-context.ts";
|
|
78
78
|
import type { ResolvedTableStyleResolution } from "../formatting/table-style-resolver.ts";
|
|
79
79
|
import { buildTableRenderPlan } from "./table-render-plan.ts";
|
|
80
|
+
import { resolvePageFieldDisplayText } from "./resolve-page-fields.ts";
|
|
80
81
|
// Geometry helpers are no longer imported here:
|
|
81
82
|
// - `hitTest` + `getAnchorRects` moved to the geometry facet in the
|
|
82
83
|
// refactor/05 Slice 6 wrapper-deletion pass (2026-04-22).
|
|
@@ -90,6 +91,7 @@ import { collectLineBoxesForRegion } from "../geometry/project-fragments.ts";
|
|
|
90
91
|
// can add its own instrumentation at call site if needed.
|
|
91
92
|
import type {
|
|
92
93
|
SurfaceBlockSnapshot,
|
|
94
|
+
SurfaceInlineSegment,
|
|
93
95
|
} from "../../api/public-types";
|
|
94
96
|
|
|
95
97
|
export type {
|
|
@@ -828,9 +830,14 @@ export function createLayoutFacet(
|
|
|
828
830
|
if (!story) return Object.freeze([]);
|
|
829
831
|
// P8.2.1 — share the projected `SurfaceBlockSnapshot[]` across every
|
|
830
832
|
// page that renders the same story target.
|
|
831
|
-
const projectedBlocks =
|
|
833
|
+
const projectedBlocks = resolvePageScopedFieldsInBlocks(
|
|
834
|
+
getStoryProjectionCached(story, document),
|
|
835
|
+
node,
|
|
836
|
+
graph,
|
|
837
|
+
);
|
|
832
838
|
return resolveHeaderFooterRegionBlocks(
|
|
833
|
-
node
|
|
839
|
+
node,
|
|
840
|
+
graph,
|
|
834
841
|
region,
|
|
835
842
|
story,
|
|
836
843
|
projectedBlocks,
|
|
@@ -1866,7 +1873,8 @@ function resolveColumnRegionBlocks(
|
|
|
1866
1873
|
}
|
|
1867
1874
|
|
|
1868
1875
|
function resolveHeaderFooterRegionBlocks(
|
|
1869
|
-
|
|
1876
|
+
node: RuntimePageNode,
|
|
1877
|
+
graph: RuntimePageGraph,
|
|
1870
1878
|
regionKind: "header" | "footer",
|
|
1871
1879
|
storyTarget: EditorStoryTarget,
|
|
1872
1880
|
projectedBlocks: readonly SurfaceBlockSnapshot[],
|
|
@@ -1875,21 +1883,117 @@ function resolveHeaderFooterRegionBlocks(
|
|
|
1875
1883
|
if (projectedBlocks.length === 0) return Object.freeze([]);
|
|
1876
1884
|
const fragmentBase = `story-${storyTarget.kind}-${storyTarget.relationshipId}-${storyTarget.variant}-${storyTarget.sectionIndex ?? "_"}`;
|
|
1877
1885
|
return Object.freeze(
|
|
1878
|
-
projectedBlocks.map((blockSnapshot, index): PublicRegionBlock =>
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1886
|
+
projectedBlocks.map((blockSnapshot, index): PublicRegionBlock => {
|
|
1887
|
+
const resolvedBlockSnapshot = resolvePageInstanceFieldsInBlock(
|
|
1888
|
+
blockSnapshot,
|
|
1889
|
+
node,
|
|
1890
|
+
graph,
|
|
1891
|
+
);
|
|
1892
|
+
return {
|
|
1893
|
+
blockId: blockSnapshot.blockId,
|
|
1894
|
+
fragmentId: `${fragmentBase}-${index}`,
|
|
1895
|
+
pageIndex: node.pageIndex,
|
|
1896
|
+
regionKind,
|
|
1897
|
+
runtimeFromOffset: blockSnapshot.from,
|
|
1898
|
+
runtimeToOffset: blockSnapshot.to,
|
|
1899
|
+
// Header/footer fragments aren't measured per-block — consumers read
|
|
1900
|
+
// the region's `heightTwips` from `getStoryRegionsOnPage`.
|
|
1901
|
+
heightTwips: 0,
|
|
1902
|
+
blockSnapshot: resolvedBlockSnapshot,
|
|
1903
|
+
};
|
|
1904
|
+
}),
|
|
1890
1905
|
);
|
|
1891
1906
|
}
|
|
1892
1907
|
|
|
1908
|
+
const PAGE_INSTANCE_FIELD_FAMILIES = new Set([
|
|
1909
|
+
"PAGE",
|
|
1910
|
+
"NUMPAGES",
|
|
1911
|
+
"SECTIONPAGES",
|
|
1912
|
+
]);
|
|
1913
|
+
|
|
1914
|
+
function resolvePageScopedFieldsInBlocks(
|
|
1915
|
+
blocks: readonly SurfaceBlockSnapshot[],
|
|
1916
|
+
page: RuntimePageNode,
|
|
1917
|
+
graph: RuntimePageGraph,
|
|
1918
|
+
): readonly SurfaceBlockSnapshot[] {
|
|
1919
|
+
let changed = false;
|
|
1920
|
+
const resolvedBlocks = blocks.map((block) => {
|
|
1921
|
+
const resolved = resolvePageInstanceFieldsInBlock(block, page, graph);
|
|
1922
|
+
if (resolved !== block) changed = true;
|
|
1923
|
+
return resolved;
|
|
1924
|
+
});
|
|
1925
|
+
return changed ? resolvedBlocks : blocks;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
function resolvePageInstanceFieldsInBlock(
|
|
1929
|
+
block: SurfaceBlockSnapshot,
|
|
1930
|
+
page: RuntimePageNode,
|
|
1931
|
+
graph: RuntimePageGraph,
|
|
1932
|
+
): SurfaceBlockSnapshot {
|
|
1933
|
+
switch (block.kind) {
|
|
1934
|
+
case "paragraph": {
|
|
1935
|
+
const segments = resolvePageInstanceFieldsInSegments(block.segments, page, graph);
|
|
1936
|
+
return segments === block.segments ? block : { ...block, segments };
|
|
1937
|
+
}
|
|
1938
|
+
case "table": {
|
|
1939
|
+
let changed = false;
|
|
1940
|
+
const rows = block.rows.map((row) => {
|
|
1941
|
+
let rowChanged = false;
|
|
1942
|
+
const cells = row.cells.map((cell) => {
|
|
1943
|
+
let cellChanged = false;
|
|
1944
|
+
const content = cell.content.map((child) => {
|
|
1945
|
+
const resolved = resolvePageInstanceFieldsInBlock(child, page, graph);
|
|
1946
|
+
if (resolved !== child) cellChanged = true;
|
|
1947
|
+
return resolved;
|
|
1948
|
+
});
|
|
1949
|
+
if (!cellChanged) return cell;
|
|
1950
|
+
rowChanged = true;
|
|
1951
|
+
return { ...cell, content };
|
|
1952
|
+
});
|
|
1953
|
+
if (!rowChanged) return row;
|
|
1954
|
+
changed = true;
|
|
1955
|
+
return { ...row, cells };
|
|
1956
|
+
});
|
|
1957
|
+
return changed ? { ...block, rows } : block;
|
|
1958
|
+
}
|
|
1959
|
+
case "sdt_block": {
|
|
1960
|
+
let changed = false;
|
|
1961
|
+
const children = block.children.map((child) => {
|
|
1962
|
+
const resolved = resolvePageInstanceFieldsInBlock(child, page, graph);
|
|
1963
|
+
if (resolved !== child) changed = true;
|
|
1964
|
+
return resolved;
|
|
1965
|
+
});
|
|
1966
|
+
return changed ? { ...block, children } : block;
|
|
1967
|
+
}
|
|
1968
|
+
default:
|
|
1969
|
+
return block;
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
function resolvePageInstanceFieldsInSegments(
|
|
1974
|
+
segments: SurfaceInlineSegment[],
|
|
1975
|
+
page: RuntimePageNode,
|
|
1976
|
+
graph: RuntimePageGraph,
|
|
1977
|
+
): SurfaceInlineSegment[] {
|
|
1978
|
+
let changed = false;
|
|
1979
|
+
const resolvedSegments = segments.map((segment) => {
|
|
1980
|
+
if (segment.kind !== "field_ref" || !PAGE_INSTANCE_FIELD_FAMILIES.has(segment.fieldFamily)) {
|
|
1981
|
+
return segment;
|
|
1982
|
+
}
|
|
1983
|
+
const displayText = resolvePageFieldDisplayText(
|
|
1984
|
+
segment.fieldFamily,
|
|
1985
|
+
segment.displayText ?? segment.label,
|
|
1986
|
+
{ page, graph },
|
|
1987
|
+
);
|
|
1988
|
+
if (segment.displayText === displayText && segment.refreshStatus === "current") {
|
|
1989
|
+
return segment;
|
|
1990
|
+
}
|
|
1991
|
+
changed = true;
|
|
1992
|
+
return { ...segment, displayText, refreshStatus: "current" as const };
|
|
1993
|
+
});
|
|
1994
|
+
return changed ? resolvedSegments : segments;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1893
1997
|
function resolveFootnoteAreaRegionBlocks(
|
|
1894
1998
|
node: RuntimePageNode,
|
|
1895
1999
|
document: CanonicalDocumentEnvelope,
|
|
@@ -1945,4 +2049,3 @@ function resolveFootnoteAreaRegionBlocks(
|
|
|
1945
2049
|
}
|
|
1946
2050
|
return Object.freeze(blocks);
|
|
1947
2051
|
}
|
|
1948
|
-
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Resolve `PAGE` and `
|
|
2
|
+
* Resolve `PAGE`, `NUMPAGES`, and `SECTIONPAGES` field values per page.
|
|
3
3
|
*
|
|
4
4
|
* These two field families (now in `SupportedFieldFamily`) compute differently
|
|
5
5
|
* from `REF` / `PAGEREF` because the resolved value depends on the *owning
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
* preserve-only text that currently sits inside the paragraph inline atom.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import type { RuntimePageGraph, RuntimePageNode } from "./page-graph.ts";
|
|
17
16
|
import type { SupportedFieldFamily } from "../../model/canonical-document.ts";
|
|
17
|
+
import { formatPageNumber } from "../formatting/field/page-number-format.ts";
|
|
18
|
+
import type { PublicPageNode } from "./public-facet.ts";
|
|
19
|
+
import type { RuntimePageGraph, RuntimePageNode } from "./page-graph.ts";
|
|
18
20
|
|
|
19
21
|
export interface PageFieldContext {
|
|
20
22
|
/** The page this header/footer copy renders on. */
|
|
@@ -23,8 +25,16 @@ export interface PageFieldContext {
|
|
|
23
25
|
graph: RuntimePageGraph;
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
export interface PublicPageFieldContext {
|
|
29
|
+
/** The page this header/footer copy renders on. */
|
|
30
|
+
page: PublicPageNode;
|
|
31
|
+
/** All pages from the same layout facet snapshot. */
|
|
32
|
+
pages: readonly PublicPageNode[];
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
/**
|
|
27
|
-
* Resolve the display text for a PAGE / NUMPAGES field on a
|
|
36
|
+
* Resolve the display text for a PAGE / NUMPAGES / SECTIONPAGES field on a
|
|
37
|
+
* specific runtime page.
|
|
28
38
|
*
|
|
29
39
|
* Returns the original cached `displayText` for unsupported families so the
|
|
30
40
|
* caller can use this as a single resolution helper.
|
|
@@ -36,11 +46,54 @@ export function resolvePageFieldDisplayText(
|
|
|
36
46
|
): string {
|
|
37
47
|
switch (family) {
|
|
38
48
|
case "PAGE":
|
|
39
|
-
return
|
|
49
|
+
return formatPageNumber(
|
|
50
|
+
context.page.stories.displayPageNumber,
|
|
51
|
+
context.page.layout?.pageNumbering?.format,
|
|
52
|
+
);
|
|
40
53
|
case "NUMPAGES":
|
|
41
54
|
// Blank fillers (evenPage/oddPage section breaks) don't count toward
|
|
42
55
|
// NUMPAGES — the graph already excludes them from contentPageCount.
|
|
43
|
-
return
|
|
56
|
+
return formatPageNumber(
|
|
57
|
+
context.graph.contentPageCount,
|
|
58
|
+
context.page.layout?.pageNumbering?.format,
|
|
59
|
+
);
|
|
60
|
+
case "SECTIONPAGES":
|
|
61
|
+
return formatPageNumber(
|
|
62
|
+
countRuntimeSectionContentPages(context.graph, context.page.sectionIndex),
|
|
63
|
+
context.page.layout?.pageNumbering?.format,
|
|
64
|
+
);
|
|
65
|
+
default:
|
|
66
|
+
return cachedDisplayText;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Public-facet equivalent used by page-stack chrome. Header/footer parts are
|
|
72
|
+
* shared stories, but each visible page copy needs its own page-field text.
|
|
73
|
+
*/
|
|
74
|
+
export function resolvePublicPageFieldDisplayText(
|
|
75
|
+
family: SupportedFieldFamily | string,
|
|
76
|
+
cachedDisplayText: string,
|
|
77
|
+
context: PublicPageFieldContext,
|
|
78
|
+
): string {
|
|
79
|
+
switch (family) {
|
|
80
|
+
case "PAGE":
|
|
81
|
+
return formatPageNumber(
|
|
82
|
+
context.page.displayPageNumber,
|
|
83
|
+
context.page.layout.pageNumbering?.format,
|
|
84
|
+
);
|
|
85
|
+
case "NUMPAGES":
|
|
86
|
+
return formatPageNumber(
|
|
87
|
+
context.pages.filter((page) => !page.isBlankFiller).length,
|
|
88
|
+
context.page.layout.pageNumbering?.format,
|
|
89
|
+
);
|
|
90
|
+
case "SECTIONPAGES":
|
|
91
|
+
return formatPageNumber(
|
|
92
|
+
context.pages.filter(
|
|
93
|
+
(page) => !page.isBlankFiller && page.sectionIndex === context.page.sectionIndex,
|
|
94
|
+
).length,
|
|
95
|
+
context.page.layout.pageNumbering?.format,
|
|
96
|
+
);
|
|
44
97
|
default:
|
|
45
98
|
return cachedDisplayText;
|
|
46
99
|
}
|
|
@@ -53,7 +106,7 @@ export function resolvePageFieldDisplayText(
|
|
|
53
106
|
*/
|
|
54
107
|
export function buildPageFieldResolutionTable(
|
|
55
108
|
graph: RuntimePageGraph,
|
|
56
|
-
families: ReadonlySet<string> = new Set(["PAGE", "NUMPAGES"]),
|
|
109
|
+
families: ReadonlySet<string> = new Set(["PAGE", "NUMPAGES", "SECTIONPAGES"]),
|
|
57
110
|
): Map<string, Map<string, string>> {
|
|
58
111
|
const result = new Map<string, Map<string, string>>();
|
|
59
112
|
for (const page of graph.pages) {
|
|
@@ -68,3 +121,12 @@ export function buildPageFieldResolutionTable(
|
|
|
68
121
|
}
|
|
69
122
|
return result;
|
|
70
123
|
}
|
|
124
|
+
|
|
125
|
+
function countRuntimeSectionContentPages(
|
|
126
|
+
graph: RuntimePageGraph,
|
|
127
|
+
sectionIndex: number,
|
|
128
|
+
): number {
|
|
129
|
+
return graph.pages.filter(
|
|
130
|
+
(page) => !page.isBlankFiller && page.sectionIndex === sectionIndex,
|
|
131
|
+
).length;
|
|
132
|
+
}
|
|
@@ -140,7 +140,7 @@ function collectInlineText(
|
|
|
140
140
|
case "field": {
|
|
141
141
|
const family = inline.fieldFamily ?? classifyFieldInstructionLocal(inline.instruction);
|
|
142
142
|
const cached = flattenInline(inline.children);
|
|
143
|
-
if (family === "PAGE" || family === "NUMPAGES") {
|
|
143
|
+
if (family === "PAGE" || family === "NUMPAGES" || family === "SECTIONPAGES") {
|
|
144
144
|
out.push(
|
|
145
145
|
resolvePageFieldDisplayText(family, cached, { page, graph }),
|
|
146
146
|
);
|
|
@@ -204,55 +204,64 @@ function collectGuardVerdict(
|
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
const guard = runtime.getInteractionGuardSnapshot();
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
207
|
+
// Scope-targeted writes target `scope.handle.scopeId`, not the current
|
|
208
|
+
// editor selection. The target scope's own overlay posture was handled
|
|
209
|
+
// above through `scope.workflow.effectiveMode`; this second guard read is
|
|
210
|
+
// only allowed to contribute global/session-wide blockers.
|
|
211
|
+
const isSelectionScopeMembershipReason = (reason: { readonly code: string; readonly scopeId?: string }): boolean => {
|
|
212
|
+
if (reason.code === "outside_workflow_scope") return true;
|
|
213
|
+
if (
|
|
214
|
+
(reason.code === "workflow_view_only" ||
|
|
215
|
+
reason.code === "workflow_comment_only") &&
|
|
216
|
+
typeof reason.scopeId === "string" &&
|
|
217
|
+
reason.scopeId.length > 0
|
|
218
|
+
) {
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
};
|
|
223
|
+
const rawReasons = guard.blockedReasons ?? [];
|
|
224
|
+
const nonSelectionScoped = rawReasons.filter(
|
|
225
|
+
(r) => !isSelectionScopeMembershipReason(r),
|
|
226
|
+
);
|
|
227
|
+
const pushTypedGuardBlocker = (code: string | undefined): void => {
|
|
228
|
+
const suffix = typeof code === "string" && code.length > 0
|
|
229
|
+
? code
|
|
230
|
+
: "unspecified";
|
|
231
|
+
const typedBlocker = `guard:block-${suffix}`;
|
|
232
|
+
if (!blockedReasons.some((existing) => existing === typedBlocker)) {
|
|
233
|
+
blockedReasons.push(typedBlocker);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
if (guard.effectiveMode === "view") {
|
|
237
|
+
const globalViewReason = nonSelectionScoped.find(
|
|
238
|
+
(reason) => reason.code === "workflow_view_only",
|
|
239
|
+
);
|
|
240
|
+
if (globalViewReason) {
|
|
241
|
+
pushTypedGuardBlocker(globalViewReason.code);
|
|
242
|
+
} else if (
|
|
243
|
+
rawReasons.length === 0 &&
|
|
244
|
+
!blockedReasons.includes("guard:view-mode-active")
|
|
245
|
+
) {
|
|
246
|
+
blockedReasons.push("guard:view-mode-active");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (guard.effectiveMode === "comment") {
|
|
250
|
+
const globalCommentReason = nonSelectionScoped.find(
|
|
251
|
+
(reason) => reason.code === "workflow_comment_only",
|
|
252
|
+
);
|
|
253
|
+
if (globalCommentReason) {
|
|
254
|
+
pushTypedGuardBlocker(globalCommentReason.code);
|
|
255
|
+
}
|
|
212
256
|
}
|
|
213
257
|
if (guard.effectiveMode === "blocked") {
|
|
214
258
|
// Coord-06 §13e — promote the bare `guard:blocked` blocker to a typed
|
|
215
|
-
// `guard:block-<reason>` suffix
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
// Scope-targeted-write carve-out (coord-09, TemplateViewer repro
|
|
221
|
-
// 2026-04-24): `applyReplacementScope`, `attachExplanation`, and
|
|
222
|
-
// `createIssue` target a scopeId, not the current editor selection.
|
|
223
|
-
// The scope's own `workflow.effectiveMode` already drove the
|
|
224
|
-
// scope-level arm of `collectGuardVerdict` above (lines 159–197).
|
|
225
|
-
// The selection-scoped coordinator guard, in contrast, evaluates
|
|
226
|
-
// against the live `state.selection` — which, for scope-targeted
|
|
227
|
-
// writes, may sit anywhere in the document. Reasons that depend on
|
|
228
|
-
// selection-scope membership (`outside_workflow_scope`,
|
|
229
|
-
// `workflow_view_only`, `workflow_comment_only`) are therefore
|
|
230
|
-
// double-counting and must not block. Globally-scoped reasons
|
|
231
|
-
// (`document_read_only`, `document_viewing_mode`) still apply — a
|
|
232
|
-
// read-only doc rejects every write, scope-targeted or not.
|
|
233
|
-
const SELECTION_SCOPE_MEMBERSHIP_CODES = new Set([
|
|
234
|
-
"outside_workflow_scope",
|
|
235
|
-
"workflow_view_only",
|
|
236
|
-
"workflow_comment_only",
|
|
237
|
-
]);
|
|
238
|
-
const rawReasons = guard.blockedReasons ?? [];
|
|
239
|
-
const nonSelectionScoped = rawReasons.filter(
|
|
240
|
-
(r) => !SELECTION_SCOPE_MEMBERSHIP_CODES.has(r.code),
|
|
241
|
-
);
|
|
242
|
-
// If every reason was selection-scope-membership for a scope-
|
|
243
|
-
// targeted write, emit no blocker — the scope-level arm above is
|
|
244
|
-
// authoritative. The defensive empty-array fallback
|
|
245
|
-
// (guard:block-unspecified) still fires when the coordinator
|
|
246
|
-
// produced effectiveMode:"blocked" without any reasons at all.
|
|
259
|
+
// `guard:block-<reason>` suffix. Selection-membership reasons are
|
|
260
|
+
// intentionally ignored here; global/session reasons such as read-only,
|
|
261
|
+
// protected ranges, shared workflow locks, and unsupported suggesting
|
|
262
|
+
// commands remain blockers.
|
|
247
263
|
if (nonSelectionScoped.length > 0 || rawReasons.length === 0) {
|
|
248
|
-
|
|
249
|
-
const suffix = typeof primaryCode === "string" && primaryCode.length > 0
|
|
250
|
-
? primaryCode
|
|
251
|
-
: "unspecified";
|
|
252
|
-
const typedBlocker = `guard:block-${suffix}`;
|
|
253
|
-
if (!blockedReasons.some((existing) => existing === typedBlocker)) {
|
|
254
|
-
blockedReasons.push(typedBlocker);
|
|
255
|
-
}
|
|
264
|
+
pushTypedGuardBlocker(nonSelectionScoped[0]?.code);
|
|
256
265
|
}
|
|
257
266
|
}
|
|
258
267
|
for (const reason of guard.blockedReasons ?? []) {
|