@beyondwork/docx-react-component 1.0.77 → 1.0.78
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/io/ooxml/parse-shapes.ts +32 -6
- package/src/model/canonical-document.ts +45 -8
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +12 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +13 -13
- package/src/ui-tailwind/page-stack/tw-active-band-ribbon.tsx +229 -0
- package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +15 -1
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +18 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +20 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +10 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +56 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beyondwork/docx-react-component",
|
|
3
3
|
"publisher": "beyondwork",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.78",
|
|
5
5
|
"description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": [
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* preserved in the canonical node's rawXml field for lossless round-trip export.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type { ShapeContent } from "../../model/canonical-document.ts";
|
|
13
|
+
import type { BlockNode, ShapeContent } from "../../model/canonical-document.ts";
|
|
14
14
|
import { parseFill } from "./parse-fill.ts";
|
|
15
15
|
import {
|
|
16
16
|
type XmlElementNode,
|
|
@@ -40,7 +40,7 @@ export interface ParsedWpsShape {
|
|
|
40
40
|
* shape-textbox paragraphs). Same shape + semantics as
|
|
41
41
|
* `ShapeContent.txbxBlocks` on the drawing-frame path.
|
|
42
42
|
*/
|
|
43
|
-
txbxBlocks?: ReadonlyArray<
|
|
43
|
+
txbxBlocks?: ReadonlyArray<BlockNode>;
|
|
44
44
|
/** DrawML geometry preset, e.g. "rect", "roundRect". */
|
|
45
45
|
geometry?: string;
|
|
46
46
|
/** Original drawing XML for lossless round-trip export. */
|
|
@@ -122,10 +122,20 @@ export function parseShapeXml(
|
|
|
122
122
|
// content (CCEP "Copyright CCEP STRICTLY CONFIDENTIAL" footer band)
|
|
123
123
|
// is reachable only via the `.text` summary string — L03 cascade +
|
|
124
124
|
// L11 render can't walk runs/marks.
|
|
125
|
-
let txbxBlocks: ReadonlyArray<
|
|
125
|
+
let txbxBlocks: ReadonlyArray<BlockNode> | undefined;
|
|
126
126
|
if (txbxContentXml && blockParser) {
|
|
127
127
|
try {
|
|
128
|
-
|
|
128
|
+
// The `blockParser` callback is supplied by parse-main-document.ts
|
|
129
|
+
// as a thin wrapper over `parseBlockStreamFromXml`. That function
|
|
130
|
+
// returns `ParsedBlockNode[]` — structurally identical to canonical
|
|
131
|
+
// `BlockNode[]` at runtime for shape-textbox content (verified on
|
|
132
|
+
// CCEP SOW footer fixture 2026-04-24: paragraph + text + TextMark
|
|
133
|
+
// shapes land end-to-end with zero `ParsedBlockNode`-only fields
|
|
134
|
+
// surfaced). The cast is safe here because the runtime output IS
|
|
135
|
+
// canonical; a structural `as unknown as BlockNode[]` preserves
|
|
136
|
+
// type safety at every consumer site (L03 cascade, L11 render,
|
|
137
|
+
// validator walk).
|
|
138
|
+
txbxBlocks = blockParser(txbxContentXml) as unknown as ReadonlyArray<BlockNode>;
|
|
129
139
|
} catch {
|
|
130
140
|
txbxBlocks = undefined;
|
|
131
141
|
}
|
|
@@ -214,6 +224,17 @@ function extractAllText(node: XmlElementNode): string {
|
|
|
214
224
|
// txbxContentXml, optional recursive txbxBlocks).
|
|
215
225
|
// ───────────────────────────────────────────────────────────────────────────
|
|
216
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Callback signature for the txbx-content block parser supplied by
|
|
229
|
+
* parse-main-document.ts / parse-headers-footers.ts. The actual
|
|
230
|
+
* implementation wraps `parseBlockStreamFromXml` which returns
|
|
231
|
+
* `ParsedBlockNode[]`; its runtime output is canonical `BlockNode[]`
|
|
232
|
+
* for shape-textbox content (no `ParsedBlockNode`-only fields surface
|
|
233
|
+
* at the shape boundary — verified on CCEP SOW footer fixture
|
|
234
|
+
* 2026-04-24). The structural `unknown` return keeps the parse layer
|
|
235
|
+
* layer-pure; `parseShapeContent` + `parseShapeXml` cast to canonical
|
|
236
|
+
* `BlockNode[]` at the assembly seam.
|
|
237
|
+
*/
|
|
217
238
|
export type TxbxBlockParser = (xml: string) => ReadonlyArray<{ type: string; [key: string]: unknown }>;
|
|
218
239
|
|
|
219
240
|
export function parseShapeContent(
|
|
@@ -240,10 +261,15 @@ export function parseShapeContent(
|
|
|
240
261
|
const txbxContent = txbx ? findFirstDescendant(txbx, "txbxContent") : undefined;
|
|
241
262
|
const txbxContentXml = txbxContent ? extractRawXml(txbxContent) : undefined;
|
|
242
263
|
|
|
243
|
-
let txbxBlocks: ReadonlyArray<
|
|
264
|
+
let txbxBlocks: ReadonlyArray<BlockNode> | undefined;
|
|
244
265
|
if (txbxContentXml && blockParser) {
|
|
245
266
|
try {
|
|
246
|
-
|
|
267
|
+
// See `TxbxBlockParser` doc above: runtime output is canonical
|
|
268
|
+
// `BlockNode[]` for shape-textbox content (verified on CCEP SOW
|
|
269
|
+
// footer fixture 2026-04-24). Cast at the assembly seam so
|
|
270
|
+
// downstream consumers (L03, L11, validator) get canonical types
|
|
271
|
+
// without local `as unknown` ceremony.
|
|
272
|
+
txbxBlocks = blockParser(txbxContentXml) as unknown as ReadonlyArray<BlockNode>;
|
|
247
273
|
} catch {
|
|
248
274
|
// Preserve-only fallback: keep txbxContentXml for serialization; leave
|
|
249
275
|
// txbxBlocks undefined so consumers know recursion did not succeed.
|
|
@@ -1786,12 +1786,29 @@ export interface SmartArtPreviewNode {
|
|
|
1786
1786
|
/**
|
|
1787
1787
|
* Read-only rendering of a wps:wsp WordprocessingShape. Text content is
|
|
1788
1788
|
* extracted for display. The original drawing XML is preserved in rawXml.
|
|
1789
|
+
*
|
|
1790
|
+
* When the shape is a text-box (`isTextBox: true`), the raw textbox XML
|
|
1791
|
+
* is preserved in `txbxContentXml` for lossless round-trip, and the
|
|
1792
|
+
* parsed block structure lands in `txbxBlocks` — canonical `BlockNode[]`
|
|
1793
|
+
* with styles already resolved (coord-02 §14 / coord-11 §22 closed L01
|
|
1794
|
+
* side 2026-04-24 in `7d87f1189`; L02 type-promoted 2026-04-24 once the
|
|
1795
|
+
* runtime contract was confirmed canonical).
|
|
1789
1796
|
*/
|
|
1790
1797
|
export interface ShapeNode {
|
|
1791
1798
|
type: "shape";
|
|
1792
1799
|
text?: string;
|
|
1793
1800
|
geometry?: string;
|
|
1794
1801
|
isTextBox?: boolean;
|
|
1802
|
+
/** Raw `<w:txbxContent>` XML, preserved for serialization + round-trip. */
|
|
1803
|
+
txbxContentXml?: string;
|
|
1804
|
+
/**
|
|
1805
|
+
* Parsed canonical block-level structure from `<w:txbxContent>`,
|
|
1806
|
+
* populated when the parse path supplies a `blockParser` callback
|
|
1807
|
+
* (headers/footers via `src/io/ooxml/parse-headers-footers.ts`;
|
|
1808
|
+
* body via `src/io/ooxml/parse-main-document.ts`). Shape + semantics
|
|
1809
|
+
* identical to `ShapeContent.txbxBlocks` on the drawing-frame path.
|
|
1810
|
+
*/
|
|
1811
|
+
txbxBlocks?: ReadonlyArray<BlockNode>;
|
|
1795
1812
|
rawXml: string;
|
|
1796
1813
|
}
|
|
1797
1814
|
|
|
@@ -1971,14 +1988,16 @@ export interface ShapeContent {
|
|
|
1971
1988
|
* Parsed block-level structure from `w:txbxContent`, populated when a
|
|
1972
1989
|
* `blockParser` callback is supplied during parse (CO4 F3.3).
|
|
1973
1990
|
*
|
|
1974
|
-
*
|
|
1975
|
-
*
|
|
1976
|
-
*
|
|
1977
|
-
*
|
|
1978
|
-
*
|
|
1979
|
-
*
|
|
1991
|
+
* Canonical `BlockNode[]` — the parse path produces fully-normalized
|
|
1992
|
+
* blocks (styles resolved, marks attached, no `ParsedBlockNode`-only
|
|
1993
|
+
* fields at runtime). Verified on the CCEP SOW footer fixture 2026-04-24:
|
|
1994
|
+
* paragraph + text + `TextMark` shapes land end-to-end. Type promoted
|
|
1995
|
+
* 2026-04-24 from the earlier weakly-typed escape hatch once the L01
|
|
1996
|
+
* shape-textbox parse (commit `7d87f1189`) confirmed the runtime
|
|
1997
|
+
* contract — unblocks L03 cascade + L11 render walking `txbxBlocks`
|
|
1998
|
+
* without `as unknown as BlockNode[]` casts at the consumer site.
|
|
1980
1999
|
*/
|
|
1981
|
-
txbxBlocks?: ReadonlyArray<
|
|
2000
|
+
txbxBlocks?: ReadonlyArray<BlockNode>;
|
|
1982
2001
|
rawXml: string;
|
|
1983
2002
|
}
|
|
1984
2003
|
|
|
@@ -2860,11 +2879,29 @@ function validateDocumentNode(
|
|
|
2860
2879
|
return;
|
|
2861
2880
|
case "chart_preview":
|
|
2862
2881
|
case "smartart_preview":
|
|
2863
|
-
case "shape":
|
|
2864
2882
|
case "wordart":
|
|
2865
2883
|
case "vml_shape":
|
|
2866
2884
|
expectString(record.rawXml, `${path}.rawXml`, issues);
|
|
2867
2885
|
return;
|
|
2886
|
+
case "shape":
|
|
2887
|
+
expectString(record.rawXml, `${path}.rawXml`, issues);
|
|
2888
|
+
if (record.txbxBlocks !== undefined) {
|
|
2889
|
+
if (!Array.isArray(record.txbxBlocks)) {
|
|
2890
|
+
issues.push({
|
|
2891
|
+
path: `${path}.txbxBlocks`,
|
|
2892
|
+
message: "shape.txbxBlocks must be an array when present.",
|
|
2893
|
+
});
|
|
2894
|
+
} else {
|
|
2895
|
+
// coord-02 §14 follow-up (2026-04-24): `ShapeNode.txbxBlocks`
|
|
2896
|
+
// is canonical `BlockNode[]`. Walk it with the same validator
|
|
2897
|
+
// used for top-level document content so run marks / paragraph
|
|
2898
|
+
// structure / nested shapes all enforce the normal rules.
|
|
2899
|
+
record.txbxBlocks.forEach((child, index) => {
|
|
2900
|
+
validateDocumentNode(child, `${path}.txbxBlocks[${index}]`, issues);
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
return;
|
|
2868
2905
|
case "drawing_frame": {
|
|
2869
2906
|
const anchor = asPlainObject(record.anchor, `${path}.anchor`, issues);
|
|
2870
2907
|
const content = asPlainObject(record.content, `${path}.content`, issues);
|
|
@@ -186,6 +186,16 @@ export interface TwChromeOverlayProps {
|
|
|
186
186
|
/** Preview catalog threaded into the page-stack chrome so header /
|
|
187
187
|
* footer / footnote / endnote regions render real <img>s. */
|
|
188
188
|
mediaPreviews?: Record<string, import("../editor-surface/pm-state-from-snapshot").MediaPreviewDescriptor>;
|
|
189
|
+
/**
|
|
190
|
+
* Slice B (§6.20 reshape) — section-properties ribbon bundle forwarded
|
|
191
|
+
* to whichever per-page header/footer band is the active story. Pass
|
|
192
|
+
* `null` (or omit) when no header/footer is active. The bundle's `kind`
|
|
193
|
+
* is set by the band itself; do not pre-pin it.
|
|
194
|
+
*/
|
|
195
|
+
activeBandRibbonProps?: Omit<
|
|
196
|
+
import("../page-stack/tw-active-band-ribbon").TwActiveBandRibbonProps,
|
|
197
|
+
"kind" | "data-testid"
|
|
198
|
+
> | null;
|
|
189
199
|
}
|
|
190
200
|
|
|
191
201
|
/**
|
|
@@ -230,6 +240,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
230
240
|
pmView,
|
|
231
241
|
visiblePageIndexRange,
|
|
232
242
|
mediaPreviews,
|
|
243
|
+
activeBandRibbonProps,
|
|
233
244
|
}) => {
|
|
234
245
|
return (
|
|
235
246
|
<div
|
|
@@ -248,6 +259,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
|
|
|
248
259
|
pmView={pmView}
|
|
249
260
|
visiblePageIndexRange={visiblePageIndexRange ?? null}
|
|
250
261
|
mediaPreviews={mediaPreviews}
|
|
262
|
+
activeBandRibbonProps={activeBandRibbonProps ?? null}
|
|
251
263
|
/>
|
|
252
264
|
) : null}
|
|
253
265
|
<TwScopeRailLayer
|
|
@@ -916,21 +916,21 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
916
916
|
viewRef.current = view;
|
|
917
917
|
recordPerfSample("pm.mount");
|
|
918
918
|
} else {
|
|
919
|
-
// Wave 1 Slice C ·
|
|
919
|
+
// Wave 1 Slice C · snapshot-replacement funnel.
|
|
920
920
|
//
|
|
921
|
-
// `replaceStatePreservingPosition`
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
// measurement on the hot path).
|
|
927
|
-
// 2. Echo-suppression ordering — `suppressSelectionEchoRef` is
|
|
928
|
-
// set to `true` BEFORE the state swap and released in a
|
|
929
|
-
// microtask AFTER, so PM's internal selection-change events
|
|
930
|
-
// during the swap are swallowed by the selection-sync
|
|
931
|
-
// plugin.
|
|
921
|
+
// `replaceStatePreservingPosition` owns the echo-suppression
|
|
922
|
+
// ordering around the state swap — suppressSelectionEchoRef is
|
|
923
|
+
// set to `true` BEFORE updateState and released in a microtask
|
|
924
|
+
// AFTER, so PM's internal selection-change events during the
|
|
925
|
+
// swap are swallowed by the selection-sync plugin.
|
|
932
926
|
//
|
|
933
|
-
//
|
|
927
|
+
// Scroll-anchor preservation (`preserveScrollAnchor: true`) is
|
|
928
|
+
// currently OFF by default after the 2026-04-24 jump-to-top
|
|
929
|
+
// regression report (see hotfix commit). Re-enable under a
|
|
930
|
+
// diagnosed-safe codepath only; the capture/restore helpers
|
|
931
|
+
// remain tested and ready.
|
|
932
|
+
//
|
|
933
|
+
// Ordering invariant is regression-guarded by
|
|
934
934
|
// `test/ui-tailwind/editor-surface/preserve-position-ordering.test.ts`.
|
|
935
935
|
replaceStatePreservingPosition(
|
|
936
936
|
{
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
|
|
4
|
+
import { TwPageRuler } from "../chrome/tw-page-ruler";
|
|
5
|
+
import type {
|
|
6
|
+
EditorViewStateSnapshot,
|
|
7
|
+
HeaderFooterLinkPatch,
|
|
8
|
+
PageLayoutSnapshot,
|
|
9
|
+
SectionBreakType,
|
|
10
|
+
SectionLayoutPatch,
|
|
11
|
+
SectionPageNumberingPatch,
|
|
12
|
+
} from "../../api/public-types";
|
|
13
|
+
import type { ActiveParagraphLayout } from "../review-workspace/paragraph-layout";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// TwActiveBandRibbon — Slice B of the §6.20 page-layout reshape.
|
|
17
|
+
//
|
|
18
|
+
// A quiet on-demand surface that floats above (header) or below (footer)
|
|
19
|
+
// an active header/footer band. Hosts the section-properties controls
|
|
20
|
+
// previously parked in `TwLayoutPanel` + `TwReviewWorkspacePageToolbar`,
|
|
21
|
+
// scoped to the active story only — matching Word's "Header & Footer"
|
|
22
|
+
// ribbon-tab mental model. Dismisses with the active band.
|
|
23
|
+
//
|
|
24
|
+
// Position context: the parent band uses `position: absolute`; this
|
|
25
|
+
// ribbon uses `position: absolute` with `bottom: 100%` (header) or
|
|
26
|
+
// `top: 100%` (footer) so it overflows the band frame without
|
|
27
|
+
// repositioning the band itself.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export interface TwActiveBandRibbonProps {
|
|
31
|
+
kind: "header" | "footer";
|
|
32
|
+
pageLayout: PageLayoutSnapshot;
|
|
33
|
+
viewState: EditorViewStateSnapshot;
|
|
34
|
+
paragraphLayout: ActiveParagraphLayout | null;
|
|
35
|
+
readOnly: boolean;
|
|
36
|
+
onCloseStory?: () => void;
|
|
37
|
+
onInsertSectionBreak?: (type: SectionBreakType) => void;
|
|
38
|
+
onUpdateSectionLayout?: (sectionIndex: number, patch: SectionLayoutPatch) => void;
|
|
39
|
+
onSetSectionPageNumbering?: (
|
|
40
|
+
sectionIndex: number,
|
|
41
|
+
patch: SectionPageNumberingPatch | null,
|
|
42
|
+
) => void;
|
|
43
|
+
onSetHeaderFooterLink?: (sectionIndex: number, patch: HeaderFooterLinkPatch) => void;
|
|
44
|
+
onSetParagraphIndentation?: React.ComponentProps<typeof TwPageRuler>["onSetIndentation"];
|
|
45
|
+
onSetParagraphTabStops?: React.ComponentProps<typeof TwPageRuler>["onSetTabStops"];
|
|
46
|
+
"data-testid"?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const TwActiveBandRibbon: React.FC<TwActiveBandRibbonProps> = React.memo(({
|
|
50
|
+
kind,
|
|
51
|
+
pageLayout,
|
|
52
|
+
viewState,
|
|
53
|
+
paragraphLayout,
|
|
54
|
+
readOnly,
|
|
55
|
+
onCloseStory,
|
|
56
|
+
onInsertSectionBreak,
|
|
57
|
+
onUpdateSectionLayout,
|
|
58
|
+
onSetSectionPageNumbering,
|
|
59
|
+
onSetHeaderFooterLink,
|
|
60
|
+
onSetParagraphIndentation,
|
|
61
|
+
onSetParagraphTabStops,
|
|
62
|
+
"data-testid": testId,
|
|
63
|
+
}) => {
|
|
64
|
+
const sectionIndex = pageLayout.sectionIndex;
|
|
65
|
+
const nextOrientation =
|
|
66
|
+
pageLayout.orientation === "portrait" ? "landscape" : "portrait";
|
|
67
|
+
const titlePageEnabled = pageLayout.differentFirstPage;
|
|
68
|
+
const numberingFormat = pageLayout.pageNumbering?.format ?? "decimal";
|
|
69
|
+
const positionStyles: React.CSSProperties =
|
|
70
|
+
kind === "header"
|
|
71
|
+
? { left: 0, right: 0, bottom: "100%" }
|
|
72
|
+
: { left: 0, right: 0, top: "100%" };
|
|
73
|
+
const linkVariant: HeaderFooterLinkPatch["variant"] =
|
|
74
|
+
pageLayout.headerVariants[0]?.variant ?? "default";
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
data-active-band-ribbon={kind}
|
|
79
|
+
data-testid={testId}
|
|
80
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
81
|
+
style={{
|
|
82
|
+
position: "absolute",
|
|
83
|
+
...positionStyles,
|
|
84
|
+
pointerEvents: "auto",
|
|
85
|
+
zIndex: 2,
|
|
86
|
+
}}
|
|
87
|
+
className="flex flex-col gap-1 rounded-md border border-border/50 bg-canvas/95 px-2 py-1 shadow-sm backdrop-blur-sm"
|
|
88
|
+
>
|
|
89
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
90
|
+
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
|
|
91
|
+
{kind === "header" ? "Header" : "Footer"} · Section {sectionIndex + 1}
|
|
92
|
+
</span>
|
|
93
|
+
{onCloseStory ? (
|
|
94
|
+
<RibbonButton
|
|
95
|
+
ariaLabel="Return to document body"
|
|
96
|
+
onClick={onCloseStory}
|
|
97
|
+
data-testid="active-band-ribbon-close"
|
|
98
|
+
>
|
|
99
|
+
Done
|
|
100
|
+
</RibbonButton>
|
|
101
|
+
) : null}
|
|
102
|
+
<span aria-hidden="true" className="mx-1 h-3 w-px bg-border/60" />
|
|
103
|
+
<RibbonButton
|
|
104
|
+
ariaLabel={`Switch section to ${nextOrientation}`}
|
|
105
|
+
disabled={readOnly || !onUpdateSectionLayout}
|
|
106
|
+
onClick={() =>
|
|
107
|
+
onUpdateSectionLayout?.(sectionIndex, {
|
|
108
|
+
pageSize: {
|
|
109
|
+
orientation: nextOrientation,
|
|
110
|
+
width: pageLayout.pageHeight,
|
|
111
|
+
height: pageLayout.pageWidth,
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
data-testid="active-band-ribbon-orientation"
|
|
116
|
+
>
|
|
117
|
+
{nextOrientation === "landscape" ? "Landscape" : "Portrait"}
|
|
118
|
+
</RibbonButton>
|
|
119
|
+
<RibbonButton
|
|
120
|
+
ariaLabel="Insert next-page section break"
|
|
121
|
+
disabled={readOnly || !onInsertSectionBreak}
|
|
122
|
+
onClick={() => onInsertSectionBreak?.("nextPage")}
|
|
123
|
+
data-testid="active-band-ribbon-section-break"
|
|
124
|
+
>
|
|
125
|
+
Section break
|
|
126
|
+
</RibbonButton>
|
|
127
|
+
<RibbonButton
|
|
128
|
+
ariaLabel="Restart page numbering at 1"
|
|
129
|
+
disabled={readOnly || !onSetSectionPageNumbering}
|
|
130
|
+
onClick={() =>
|
|
131
|
+
onSetSectionPageNumbering?.(sectionIndex, {
|
|
132
|
+
...(pageLayout.pageNumbering ?? {}),
|
|
133
|
+
start: 1,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
data-testid="active-band-ribbon-restart-numbering"
|
|
137
|
+
>
|
|
138
|
+
Restart numbering
|
|
139
|
+
</RibbonButton>
|
|
140
|
+
<RibbonButton
|
|
141
|
+
ariaLabel={
|
|
142
|
+
numberingFormat === "roman"
|
|
143
|
+
? "Switch numbering to decimal"
|
|
144
|
+
: "Switch numbering to roman"
|
|
145
|
+
}
|
|
146
|
+
disabled={readOnly || !onSetSectionPageNumbering}
|
|
147
|
+
onClick={() =>
|
|
148
|
+
onSetSectionPageNumbering?.(sectionIndex, {
|
|
149
|
+
...(pageLayout.pageNumbering ?? {}),
|
|
150
|
+
format: numberingFormat === "roman" ? "decimal" : "roman",
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
data-testid="active-band-ribbon-numbering-format"
|
|
154
|
+
>
|
|
155
|
+
{numberingFormat === "roman" ? "Decimal" : "Roman"}
|
|
156
|
+
</RibbonButton>
|
|
157
|
+
<RibbonButton
|
|
158
|
+
ariaLabel="Toggle different first page"
|
|
159
|
+
disabled={readOnly || !onUpdateSectionLayout}
|
|
160
|
+
onClick={() =>
|
|
161
|
+
onUpdateSectionLayout?.(sectionIndex, {
|
|
162
|
+
titlePage: !titlePageEnabled,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
data-testid="active-band-ribbon-title-page"
|
|
166
|
+
>
|
|
167
|
+
{titlePageEnabled ? "Same first page" : "Different first page"}
|
|
168
|
+
</RibbonButton>
|
|
169
|
+
{sectionIndex > 0 && onSetHeaderFooterLink ? (
|
|
170
|
+
<RibbonButton
|
|
171
|
+
ariaLabel={`Link ${kind} to previous section`}
|
|
172
|
+
disabled={readOnly}
|
|
173
|
+
onClick={() =>
|
|
174
|
+
onSetHeaderFooterLink(sectionIndex, {
|
|
175
|
+
kind,
|
|
176
|
+
variant: linkVariant,
|
|
177
|
+
linkToPrevious: true,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
data-testid="active-band-ribbon-link-previous"
|
|
181
|
+
>
|
|
182
|
+
Link to previous
|
|
183
|
+
</RibbonButton>
|
|
184
|
+
) : null}
|
|
185
|
+
</div>
|
|
186
|
+
<TwPageRuler
|
|
187
|
+
pageLayout={pageLayout}
|
|
188
|
+
viewState={viewState}
|
|
189
|
+
paragraphLayout={paragraphLayout}
|
|
190
|
+
readOnly={readOnly}
|
|
191
|
+
onReturnToBody={onCloseStory ?? (() => undefined)}
|
|
192
|
+
onSetIndentation={onSetParagraphIndentation}
|
|
193
|
+
onSetTabStops={onSetParagraphTabStops}
|
|
194
|
+
/>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
interface RibbonButtonProps {
|
|
200
|
+
ariaLabel: string;
|
|
201
|
+
children: React.ReactNode;
|
|
202
|
+
disabled?: boolean;
|
|
203
|
+
onClick: () => void;
|
|
204
|
+
"data-testid"?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function RibbonButton({
|
|
208
|
+
ariaLabel,
|
|
209
|
+
children,
|
|
210
|
+
disabled,
|
|
211
|
+
onClick,
|
|
212
|
+
"data-testid": testId,
|
|
213
|
+
}: RibbonButtonProps): React.ReactElement {
|
|
214
|
+
return (
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
aria-label={ariaLabel}
|
|
218
|
+
disabled={disabled}
|
|
219
|
+
data-testid={testId}
|
|
220
|
+
onMouseDown={preserveEditorSelectionMouseDown}
|
|
221
|
+
onClick={onClick}
|
|
222
|
+
className="inline-flex h-6 items-center rounded px-2 text-[11px] font-medium text-primary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40"
|
|
223
|
+
>
|
|
224
|
+
{children}
|
|
225
|
+
</button>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export default TwActiveBandRibbon;
|
|
@@ -27,6 +27,7 @@ import { TwPageHeaderBand } from "./tw-page-header-band.tsx";
|
|
|
27
27
|
import { TwPageFooterBand } from "./tw-page-footer-band.tsx";
|
|
28
28
|
import { TwFootnoteArea } from "./tw-footnote-area.tsx";
|
|
29
29
|
import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
|
|
30
|
+
import type { TwActiveBandRibbonProps } from "./tw-active-band-ribbon.tsx";
|
|
30
31
|
|
|
31
32
|
export interface TwPageChromeEntryProps {
|
|
32
33
|
rect: PageOverlayRect;
|
|
@@ -40,6 +41,15 @@ export interface TwPageChromeEntryProps {
|
|
|
40
41
|
/** Preview catalog threaded into header/footer/footnote region renderers
|
|
41
42
|
* so images (CCEP logos on 7-of-8 CCEP docs) render as real <img>s. */
|
|
42
43
|
mediaPreviews?: Record<string, MediaPreviewDescriptor>;
|
|
44
|
+
/**
|
|
45
|
+
* Slice B (§6.20 reshape) — section-properties ribbon bundle forwarded
|
|
46
|
+
* to whichever band is active for this page. The bundle's `pageLayout`
|
|
47
|
+
* + `viewState` + `paragraphLayout` already reflect the active story;
|
|
48
|
+
* `kind` is set by the band itself. Pass `null` (or omit) when no
|
|
49
|
+
* header/footer is active anywhere — keeps the inactive-page memo
|
|
50
|
+
* stable across activate/deactivate cycles on a different page.
|
|
51
|
+
*/
|
|
52
|
+
activeBandRibbonProps?: Omit<TwActiveBandRibbonProps, "kind" | "data-testid"> | null;
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
function TwPageChromeEntryInner({
|
|
@@ -52,6 +62,7 @@ function TwPageChromeEntryInner({
|
|
|
52
62
|
visiblePageIndexRange,
|
|
53
63
|
renderFrameRevision,
|
|
54
64
|
mediaPreviews,
|
|
65
|
+
activeBandRibbonProps,
|
|
55
66
|
}: TwPageChromeEntryProps): React.ReactElement {
|
|
56
67
|
const layout = page.layout;
|
|
57
68
|
const headerStory = page.stories.header;
|
|
@@ -192,6 +203,7 @@ function TwPageChromeEntryInner({
|
|
|
192
203
|
sectionLabel={headerActive ? headerSectionLabel : undefined}
|
|
193
204
|
onClick={handleHeaderClick}
|
|
194
205
|
mediaPreviews={mediaPreviews}
|
|
206
|
+
ribbonProps={headerActive ? activeBandRibbonProps ?? null : null}
|
|
195
207
|
/>
|
|
196
208
|
) : null}
|
|
197
209
|
{footerRegion && footerStory ? (
|
|
@@ -206,6 +218,7 @@ function TwPageChromeEntryInner({
|
|
|
206
218
|
sectionLabel={footerActive ? footerSectionLabel : undefined}
|
|
207
219
|
onClick={handleFooterClick}
|
|
208
220
|
mediaPreviews={mediaPreviews}
|
|
221
|
+
ribbonProps={footerActive ? activeBandRibbonProps ?? null : null}
|
|
209
222
|
/>
|
|
210
223
|
) : null}
|
|
211
224
|
{footnoteRegion ? (
|
|
@@ -249,7 +262,8 @@ function propsAreEqual(
|
|
|
249
262
|
prev.rect.topPx === next.rect.topPx &&
|
|
250
263
|
prev.rect.bottomPx === next.rect.bottomPx &&
|
|
251
264
|
prev.rect.pageId === next.rect.pageId &&
|
|
252
|
-
prev.mediaPreviews === next.mediaPreviews
|
|
265
|
+
prev.mediaPreviews === next.mediaPreviews &&
|
|
266
|
+
prev.activeBandRibbonProps === next.activeBandRibbonProps
|
|
253
267
|
);
|
|
254
268
|
}
|
|
255
269
|
|
|
@@ -3,6 +3,10 @@ import React from "react";
|
|
|
3
3
|
import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
|
|
4
4
|
import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
|
|
5
5
|
import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
|
|
6
|
+
import {
|
|
7
|
+
TwActiveBandRibbon,
|
|
8
|
+
type TwActiveBandRibbonProps,
|
|
9
|
+
} from "./tw-active-band-ribbon.tsx";
|
|
6
10
|
|
|
7
11
|
// ---------------------------------------------------------------------------
|
|
8
12
|
// TwPageFooterBand (P8.5)
|
|
@@ -36,6 +40,12 @@ export interface TwPageFooterBandProps {
|
|
|
36
40
|
onClick: () => void;
|
|
37
41
|
"data-testid"?: string;
|
|
38
42
|
mediaPreviews?: Record<string, MediaPreviewDescriptor>;
|
|
43
|
+
/**
|
|
44
|
+
* Slice B (§6.20 reshape) — section-properties ribbon that floats
|
|
45
|
+
* below the footer band when it is the active story slot. See
|
|
46
|
+
* `TwPageHeaderBandProps.ribbonProps` for shape rationale.
|
|
47
|
+
*/
|
|
48
|
+
ribbonProps?: Omit<TwActiveBandRibbonProps, "kind" | "data-testid"> | null;
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
|
|
@@ -50,6 +60,7 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
|
|
|
50
60
|
onClick,
|
|
51
61
|
"data-testid": testId,
|
|
52
62
|
mediaPreviews,
|
|
63
|
+
ribbonProps,
|
|
53
64
|
}) => {
|
|
54
65
|
return (
|
|
55
66
|
<div
|
|
@@ -73,6 +84,13 @@ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = React.memo(({
|
|
|
73
84
|
{sectionLabel}
|
|
74
85
|
</span>
|
|
75
86
|
) : null}
|
|
87
|
+
{isActiveSlot && ribbonProps ? (
|
|
88
|
+
<TwActiveBandRibbon
|
|
89
|
+
kind="footer"
|
|
90
|
+
{...ribbonProps}
|
|
91
|
+
data-testid={testId ? `${testId}-ribbon` : undefined}
|
|
92
|
+
/>
|
|
93
|
+
) : null}
|
|
76
94
|
{isActiveSlot ? (
|
|
77
95
|
<div
|
|
78
96
|
data-pm-portal-slot
|
|
@@ -3,6 +3,10 @@ import React from "react";
|
|
|
3
3
|
import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
|
|
4
4
|
import type { MediaPreviewDescriptor } from "../editor-surface/pm-state-from-snapshot.ts";
|
|
5
5
|
import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
|
|
6
|
+
import {
|
|
7
|
+
TwActiveBandRibbon,
|
|
8
|
+
type TwActiveBandRibbonProps,
|
|
9
|
+
} from "./tw-active-band-ribbon.tsx";
|
|
6
10
|
|
|
7
11
|
// ---------------------------------------------------------------------------
|
|
8
12
|
// TwPageHeaderBand (P8.5)
|
|
@@ -40,6 +44,14 @@ export interface TwPageHeaderBandProps {
|
|
|
40
44
|
* (CCEP logos on 7-of-8 CCEP docs) render as real <img>s instead of
|
|
41
45
|
* the 48×32 placeholder chip. */
|
|
42
46
|
mediaPreviews?: Record<string, MediaPreviewDescriptor>;
|
|
47
|
+
/**
|
|
48
|
+
* Slice B (§6.20 reshape) — section-properties ribbon that floats
|
|
49
|
+
* above the band when it is the active story slot. Omit (or pass
|
|
50
|
+
* `null`) to suppress the ribbon — `isActiveSlot` alone is not
|
|
51
|
+
* sufficient because some hosts mount the band in active mode without
|
|
52
|
+
* surfacing layout controls (e.g. headless / read-only previews).
|
|
53
|
+
*/
|
|
54
|
+
ribbonProps?: Omit<TwActiveBandRibbonProps, "kind" | "data-testid"> | null;
|
|
43
55
|
}
|
|
44
56
|
|
|
45
57
|
export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
|
|
@@ -54,6 +66,7 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
|
|
|
54
66
|
onClick,
|
|
55
67
|
"data-testid": testId,
|
|
56
68
|
mediaPreviews,
|
|
69
|
+
ribbonProps,
|
|
57
70
|
}) => {
|
|
58
71
|
return (
|
|
59
72
|
<div
|
|
@@ -77,6 +90,13 @@ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = React.memo(({
|
|
|
77
90
|
{sectionLabel}
|
|
78
91
|
</span>
|
|
79
92
|
) : null}
|
|
93
|
+
{isActiveSlot && ribbonProps ? (
|
|
94
|
+
<TwActiveBandRibbon
|
|
95
|
+
kind="header"
|
|
96
|
+
{...ribbonProps}
|
|
97
|
+
data-testid={testId ? `${testId}-ribbon` : undefined}
|
|
98
|
+
/>
|
|
99
|
+
) : null}
|
|
80
100
|
{isActiveSlot ? (
|
|
81
101
|
<div
|
|
82
102
|
data-pm-portal-slot
|
|
@@ -80,6 +80,7 @@ import {
|
|
|
80
80
|
} from "../chrome-overlay/tw-page-stack-overlay-layer.tsx";
|
|
81
81
|
import { TwEndnoteArea } from "./tw-endnote-area.tsx";
|
|
82
82
|
import { TwPageChromeEntry } from "./tw-page-chrome-entry.tsx";
|
|
83
|
+
import type { TwActiveBandRibbonProps } from "./tw-active-band-ribbon.tsx";
|
|
83
84
|
|
|
84
85
|
/**
|
|
85
86
|
* Minimal structural type for the PM `EditorView` handle consumed by
|
|
@@ -149,6 +150,13 @@ export interface TwPageStackChromeLayerProps {
|
|
|
149
150
|
* in headers/footers/footnote bodies render as real <img>s. Without
|
|
150
151
|
* this, image segments fall back to the 48×32 gray placeholder chip. */
|
|
151
152
|
mediaPreviews?: Record<string, import("../editor-surface/pm-state-from-snapshot.ts").MediaPreviewDescriptor>;
|
|
153
|
+
/**
|
|
154
|
+
* Slice B (§6.20 reshape) — section-properties ribbon bundle forwarded
|
|
155
|
+
* to whichever page's active band renders it. Pass `null` (or omit)
|
|
156
|
+
* when no header/footer is active to keep memo equality stable for
|
|
157
|
+
* inactive sessions.
|
|
158
|
+
*/
|
|
159
|
+
activeBandRibbonProps?: Omit<TwActiveBandRibbonProps, "kind" | "data-testid"> | null;
|
|
152
160
|
}
|
|
153
161
|
|
|
154
162
|
const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
|
|
@@ -162,6 +170,7 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
|
|
|
162
170
|
visiblePageIndexRange,
|
|
163
171
|
"data-testid": testId,
|
|
164
172
|
mediaPreviews,
|
|
173
|
+
activeBandRibbonProps,
|
|
165
174
|
}) => {
|
|
166
175
|
const [rects, setRects] = React.useState<readonly PageOverlayRect[]>([]);
|
|
167
176
|
const overlayRootRef = React.useRef<HTMLDivElement | null>(null);
|
|
@@ -407,6 +416,7 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
|
|
|
407
416
|
visiblePageIndexRange={visiblePageIndexRange}
|
|
408
417
|
renderFrameRevision={renderFrameRevision}
|
|
409
418
|
mediaPreviews={mediaPreviews}
|
|
419
|
+
activeBandRibbonProps={activeBandRibbonProps}
|
|
410
420
|
/>
|
|
411
421
|
);
|
|
412
422
|
})}
|
|
@@ -220,12 +220,14 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
220
220
|
viewState.selection.activeRange.kind === "node"
|
|
221
221
|
? viewState.selection.activeRange.at
|
|
222
222
|
: viewState.selection.head;
|
|
223
|
-
// Slice
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
const shouldResolveActiveParagraphLayout =
|
|
223
|
+
// Slice B (designsystem §6.20 reshape): the active-band ribbon's
|
|
224
|
+
// `TwPageRuler` reads `activeParagraphLayout` when a header or footer
|
|
225
|
+
// story is the active slot, so the resolver runs only while that ribbon
|
|
226
|
+
// is mounted. Body-mode editing skips the resolver — no consumer reads
|
|
227
|
+
// it on the body path.
|
|
228
|
+
const shouldResolveActiveParagraphLayout =
|
|
229
|
+
snapshot.activeStory.kind === "header" ||
|
|
230
|
+
snapshot.activeStory.kind === "footer";
|
|
229
231
|
const effectiveSelectionMode = props.interactionGuardSnapshot?.effectiveMode ?? "edit";
|
|
230
232
|
const allowLocalChromeMutations = Boolean(caps?.canEdit) && effectiveSelectionMode === "edit";
|
|
231
233
|
const {
|
|
@@ -333,6 +335,53 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
333
335
|
onDismissSelectionToolbar: props.onDismissSelectionToolbar,
|
|
334
336
|
});
|
|
335
337
|
|
|
338
|
+
// Slice B (designsystem §6.20 reshape) — section-properties ribbon
|
|
339
|
+
// bundle. Built only while a header or footer band is the active
|
|
340
|
+
// story; the chrome layer forwards it to whichever band rendered the
|
|
341
|
+
// ribbon. Document-level read-only governs the controls (the
|
|
342
|
+
// pageChromeReadOnly flag also disables on header/footer-active, which
|
|
343
|
+
// is the wrong polarity here — the ribbon's whole point is editing
|
|
344
|
+
// section properties from inside the header/footer).
|
|
345
|
+
const ribbonReadOnly =
|
|
346
|
+
snapshot.readOnly || effectiveSelectionMode !== "edit";
|
|
347
|
+
const activeBandRibbonProps = useMemo(() => {
|
|
348
|
+
if (
|
|
349
|
+
snapshot.activeStory.kind !== "header" &&
|
|
350
|
+
snapshot.activeStory.kind !== "footer"
|
|
351
|
+
) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
if (!snapshot.pageLayout) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
pageLayout: snapshot.pageLayout,
|
|
359
|
+
viewState,
|
|
360
|
+
paragraphLayout: activeParagraphLayout,
|
|
361
|
+
readOnly: ribbonReadOnly,
|
|
362
|
+
onCloseStory: props.onCloseStory,
|
|
363
|
+
onInsertSectionBreak: props.onInsertSectionBreak,
|
|
364
|
+
onUpdateSectionLayout: props.onUpdateSectionLayout,
|
|
365
|
+
onSetSectionPageNumbering: props.onSetSectionPageNumbering,
|
|
366
|
+
onSetHeaderFooterLink: props.onSetHeaderFooterLink,
|
|
367
|
+
onSetParagraphIndentation: props.onSetParagraphIndentation,
|
|
368
|
+
onSetParagraphTabStops: props.onSetParagraphTabStops,
|
|
369
|
+
};
|
|
370
|
+
}, [
|
|
371
|
+
snapshot.activeStory.kind,
|
|
372
|
+
snapshot.pageLayout,
|
|
373
|
+
viewState,
|
|
374
|
+
activeParagraphLayout,
|
|
375
|
+
ribbonReadOnly,
|
|
376
|
+
props.onCloseStory,
|
|
377
|
+
props.onInsertSectionBreak,
|
|
378
|
+
props.onUpdateSectionLayout,
|
|
379
|
+
props.onSetSectionPageNumbering,
|
|
380
|
+
props.onSetHeaderFooterLink,
|
|
381
|
+
props.onSetParagraphIndentation,
|
|
382
|
+
props.onSetParagraphTabStops,
|
|
383
|
+
]);
|
|
384
|
+
|
|
336
385
|
// Audit §2.4 — the shell header is ALWAYS present in default composition.
|
|
337
386
|
// When the host does not supply a pre-assembled shell node, fall back to
|
|
338
387
|
// a default TwShellHeader wired to the workspace's editor-role state so
|
|
@@ -1010,6 +1059,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
|
|
|
1010
1059
|
pmSurfaceElement={pmSurfaceElement}
|
|
1011
1060
|
visiblePageIndexRange={visiblePageIndexRange}
|
|
1012
1061
|
mediaPreviews={props.mediaPreviews}
|
|
1062
|
+
activeBandRibbonProps={activeBandRibbonProps}
|
|
1013
1063
|
/>
|
|
1014
1064
|
) : null}
|
|
1015
1065
|
</div>
|