@beyondwork/docx-react-component 1.0.77 → 1.0.79

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.
@@ -569,6 +569,19 @@ export interface WordReviewEditorLayoutFacet {
569
569
  */
570
570
  getTableBodyYOffsetOnPage(blockId: string, pageIndex: number): number | null;
571
571
 
572
+ /**
573
+ * Viewport-cull height resolver — returns total rendered height (twips)
574
+ * for every block in the current page graph, computed as the sum of each
575
+ * fragment's `heightTwips` grouped by `blockId`. Consumers (the render
576
+ * surface builder in particular) use this to size `placeholder-culled`
577
+ * opaque stubs so the scrollable canvas does not change height when a
578
+ * block realizes during scroll.
579
+ *
580
+ * Returns an empty map on the inert facet or before the first successful
581
+ * pagination pass. Cached per `graph.revision`.
582
+ */
583
+ getBlockHeightsTwips(): ReadonlyMap<string, number>;
584
+
572
585
  // Fields ---------------------------------------------------------------
573
586
  getDirtyFieldFamilies(): readonly string[];
574
587
  getFieldDirtinessReport(): PublicFieldDirtinessReport;
@@ -707,6 +720,13 @@ export function createLayoutFacet(
707
720
  revision: number;
708
721
  blocks: readonly PublicRegionBlock[] | null;
709
722
  } = { revision: -1, blocks: null };
723
+ // Viewport-cull flicker fix — per-revision cache for getBlockHeightsTwips.
724
+ // One entry per blockId; value is the sum of that block's fragments'
725
+ // `heightTwips`. Busts on `graph.revision` change.
726
+ let blockHeightsCache: {
727
+ revision: number;
728
+ map: ReadonlyMap<string, number> | null;
729
+ } = { revision: -1, map: null };
710
730
 
711
731
  function resolveCanonicalDocument(): CanonicalDocumentEnvelope {
712
732
  if (input.canonicalDocument) {
@@ -1234,6 +1254,21 @@ export function createLayoutFacet(
1234
1254
  return null;
1235
1255
  },
1236
1256
 
1257
+ getBlockHeightsTwips() {
1258
+ const graph = currentGraph();
1259
+ if (blockHeightsCache.revision === graph.revision && blockHeightsCache.map) {
1260
+ return blockHeightsCache.map;
1261
+ }
1262
+ const map = new Map<string, number>();
1263
+ for (const frag of graph.fragments) {
1264
+ const prev = map.get(frag.blockId) ?? 0;
1265
+ map.set(frag.blockId, prev + frag.heightTwips);
1266
+ }
1267
+ const frozen: ReadonlyMap<string, number> = map;
1268
+ blockHeightsCache = { revision: graph.revision, map: frozen };
1269
+ return frozen;
1270
+ },
1271
+
1237
1272
  getDirtyFieldFamilies() {
1238
1273
  return engine.getDirtyFieldFamilies();
1239
1274
  },
@@ -791,19 +791,72 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
791
791
  return { scopeId, anchor: params.anchor };
792
792
  }
793
793
 
794
- const { document: nextDocument } = insertScopeMarkers(
795
- deps.getDocument(),
796
- { scopeId, from: anchor.from, to: anchor.to },
797
- );
794
+ const plantResult = insertScopeMarkers(deps.getDocument(), {
795
+ scopeId,
796
+ from: anchor.from,
797
+ to: anchor.to,
798
+ });
798
799
 
799
- if (nextDocument !== deps.getDocument()) {
800
- deps.dispatch({
801
- type: "document.replace",
802
- document: nextDocument,
803
- origin: { source: "api", at: clock() },
804
- });
800
+ // Plant failed pre-2026-04-24 this returned silently with a dead
801
+ // scopeId; callers later saw `scope-not-resolvable`. Now surface
802
+ // the typed failure on the AddScopeResult so consumers can detect
803
+ // the plant-failed path without a round-trip through
804
+ // resolveReference.
805
+ if (plantResult.status !== "planted") {
806
+ const callerAssoc: { readonly start: -1 | 1; readonly end: -1 | 1 } =
807
+ params.anchor.kind === "range"
808
+ ? params.anchor.assoc
809
+ : { start: -1, end: 1 };
810
+ // Return the caller's input range as an informational range
811
+ // anchor. The authoritative failure signal is `scopeId: ""` +
812
+ // `plantStatus.planted === false`. The detached-anchor shape has
813
+ // a fixed reason enum (`deleted|invalidatedByStructureChange|
814
+ // importAmbiguity`) that doesn't cover plant-refused, so we keep
815
+ // the range kind and let callers discriminate via plantStatus.
816
+ return {
817
+ scopeId: "",
818
+ anchor: {
819
+ kind: "range",
820
+ from: anchor.from,
821
+ to: anchor.to,
822
+ assoc: callerAssoc,
823
+ },
824
+ plantStatus: {
825
+ planted: false,
826
+ reason: plantResult.status,
827
+ ...(plantResult.status === "cross-paragraph-range"
828
+ ? {
829
+ fromBlockIndex: plantResult.fromBlockIndex,
830
+ toBlockIndex: plantResult.toBlockIndex,
831
+ }
832
+ : {}),
833
+ ...(plantResult.status === "non-paragraph-target"
834
+ ? {
835
+ blockIndex: plantResult.blockIndex,
836
+ blockKind: plantResult.blockKind,
837
+ }
838
+ : {}),
839
+ ...(plantResult.status === "range-out-of-bounds"
840
+ ? { storyLength: plantResult.storyLength }
841
+ : {}),
842
+ requestedFrom: plantResult.from,
843
+ requestedTo: plantResult.to,
844
+ },
845
+ };
846
+ // Intentionally NOT dispatching document.replace or workflow.set-overlay —
847
+ // a failed plant must not leave a half-registered scope. Prevents the
848
+ // pre-fix "overlay carries scopeId but canonical tree has no markers"
849
+ // state that produced `scope-not-resolvable` on every follow-up call.
805
850
  }
806
851
 
852
+ const nextDocument = plantResult.document;
853
+
854
+ deps.dispatch({
855
+ type: "document.replace",
856
+ document: nextDocument,
857
+ origin: { source: "api", at: clock() },
858
+ });
859
+
807
860
  // Coord-06 §13d — preserve the caller's assoc on the public anchor.
808
861
  // resolveScope re-derives the range from the inserted markers but emits
809
862
  // a hardcoded { start: -1, end: 1 }; without this override the caller's
@@ -276,10 +276,19 @@ export type CreateScopeFromAnchorResult =
276
276
  readonly reason:
277
277
  | "from-negative"
278
278
  | "to-less-than-from"
279
- | "range-exceeds-story-length";
279
+ | "range-exceeds-story-length"
280
+ | "cross-paragraph-range"
281
+ | "non-paragraph-target"
282
+ | "empty-document";
280
283
  readonly from: number;
281
284
  readonly to: number;
282
285
  readonly storyLength: number;
286
+ /** Cross-paragraph only — the two block indices the range straddled. */
287
+ readonly fromBlockIndex?: number;
288
+ readonly toBlockIndex?: number;
289
+ /** Non-paragraph target only — the offending block's index and kind. */
290
+ readonly blockIndex?: number;
291
+ readonly blockKind?: string;
283
292
  /**
284
293
  * Single-sentence, agent-actionable explanation. Tells the caller
285
294
  * what the failure was and the concrete next step — no guesswork
@@ -291,7 +300,9 @@ export type CreateScopeFromAnchorResult =
291
300
  * Short machine-routable next-step hint for thin consumers that
292
301
  * don't want to pattern-match on `reason`. Examples:
293
302
  * "clamp-from-to-zero", "swap-from-and-to",
294
- * "clamp-to-to-storyLength-or-pick-a-different-range".
303
+ * "clamp-to-to-storyLength-or-pick-a-different-range",
304
+ * "narrow-to-single-paragraph",
305
+ * "pick-a-paragraph-target".
295
306
  */
296
307
  readonly nextStep: string;
297
308
  };
@@ -420,5 +431,82 @@ export function createScopeFromAnchor(
420
431
  ...(scopeMetadataFields.length > 0 ? { scopeMetadataFields } : {}),
421
432
  });
422
433
 
434
+ // Pre-2026-04-24 the coordinator silently returned a minted scopeId
435
+ // even when insertScopeMarkers refused to plant (cross-paragraph
436
+ // range, non-paragraph target, out-of-bounds after the story-length
437
+ // check passed). Now the coordinator surfaces `plantStatus.planted:
438
+ // false`; translate each reason into the same `range-invalid` shape
439
+ // used by the bounds checks above so the caller gets one uniform
440
+ // discriminator to branch on.
441
+ if (result.plantStatus && result.plantStatus.planted === false) {
442
+ const ps = result.plantStatus;
443
+ if (ps.reason === "cross-paragraph-range") {
444
+ return {
445
+ status: "range-invalid",
446
+ reason: "cross-paragraph-range",
447
+ from,
448
+ to,
449
+ storyLength,
450
+ fromBlockIndex: ps.fromBlockIndex ?? -1,
451
+ toBlockIndex: ps.toBlockIndex ?? -1,
452
+ message:
453
+ `createScopeFromAnchor refused: range [${from}, ${to}] straddles ` +
454
+ `paragraphs ${ps.fromBlockIndex} and ${ps.toBlockIndex}. Marker-backed ` +
455
+ `scopes only plant inside a single paragraph today. Narrow the range to ` +
456
+ `land inside one paragraph, or create two separate scopes.`,
457
+ nextStep: "narrow-to-single-paragraph",
458
+ };
459
+ }
460
+ if (ps.reason === "non-paragraph-target") {
461
+ return {
462
+ status: "range-invalid",
463
+ reason: "non-paragraph-target",
464
+ from,
465
+ to,
466
+ storyLength,
467
+ blockIndex: ps.blockIndex ?? -1,
468
+ blockKind: ps.blockKind ?? "unknown",
469
+ message:
470
+ `createScopeFromAnchor refused: range [${from}, ${to}] targets a ` +
471
+ `${ps.blockKind ?? "non-paragraph"} block (index ${ps.blockIndex}). ` +
472
+ `Marker scopes only plant inside paragraphs today. Pick a paragraph ` +
473
+ `target, or use runtime.workflow.createScope({blockId}) for ` +
474
+ `whole-block scopes on the containing structure.`,
475
+ nextStep: "pick-a-paragraph-target",
476
+ };
477
+ }
478
+ if (ps.reason === "range-out-of-bounds") {
479
+ // Shouldn't happen — storyLength was checked above — but surface
480
+ // it as a first-class failure in case the underlying length math
481
+ // drifts from our bounds check.
482
+ return {
483
+ status: "range-invalid",
484
+ reason: "range-exceeds-story-length",
485
+ from,
486
+ to,
487
+ storyLength: ps.storyLength ?? storyLength,
488
+ message:
489
+ `createScopeFromAnchor refused: coordinator reports range [${from}, ${to}] ` +
490
+ `is out of bounds (storyLength=${ps.storyLength}). This is usually a ` +
491
+ `stale-offset bug (KI-P9) — re-derive positions from the current ` +
492
+ `document and retry.`,
493
+ nextStep: "clamp-to-to-storyLength-or-pick-a-different-range",
494
+ };
495
+ }
496
+ // empty-document — target has no canonical blocks.
497
+ return {
498
+ status: "range-invalid",
499
+ reason: "empty-document",
500
+ from,
501
+ to,
502
+ storyLength,
503
+ message:
504
+ `createScopeFromAnchor refused: the target document has no blocks; ` +
505
+ `cannot plant scope markers. Open or initialize a document before ` +
506
+ `creating sub-block scopes.`,
507
+ nextStep: "initialize-document-before-creating-scopes",
508
+ };
509
+ }
510
+
423
511
  return { status: "created", scopeId: result.scopeId, anchor: result.anchor };
424
512
  }
@@ -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
@@ -199,6 +199,17 @@ export const editorSchema = new Schema({
199
199
  pageBreakBefore: { default: null },
200
200
  hiddenTextOnly: { default: null },
201
201
  placeholderCulled: { default: null },
202
+ /**
203
+ * Rendered height (in twips) of the block that this placeholder
204
+ * stands in for, supplied by `DocumentRuntime` from L04's page
205
+ * graph. When present on a `placeholderCulled` paragraph, `toDOM`
206
+ * emits a fixed-height `<div>` (`${twips/20}pt`) instead of the
207
+ * `min-height: 20px` fallback, eliminating the scroll-path
208
+ * "paragraphs jump around pagination gaps" flicker that occurred
209
+ * when blocks realized at real heights larger than one line.
210
+ * Null / undefined preserves the pre-existing 20 px minimum.
211
+ */
212
+ placeholderHeightTwips: { default: null },
202
213
  blockId: { default: null },
203
214
  /**
204
215
  * `<w:framePr>` projection from `SurfaceBlockFragment.frameProperties`
@@ -214,6 +225,11 @@ export const editorSchema = new Schema({
214
225
  toDOM(node) {
215
226
  // Viewport-culled placeholder paragraph — cheap size-preserving leaf.
216
227
  if (node.attrs.placeholderCulled) {
228
+ const heightTwips = node.attrs.placeholderHeightTwips as number | null;
229
+ const heightStyle =
230
+ typeof heightTwips === "number" && heightTwips > 0
231
+ ? `height: ${heightTwips / 20}pt`
232
+ : "min-height: 20px";
217
233
  return [
218
234
  "div",
219
235
  {
@@ -221,7 +237,10 @@ export const editorSchema = new Schema({
221
237
  "data-placeholder-culled": "true",
222
238
  "data-placeholder-size": String(node.nodeSize),
223
239
  "data-placeholder-block-id": node.attrs.blockId ?? "",
224
- style: "min-height: 20px; contain: strict;",
240
+ ...(typeof heightTwips === "number" && heightTwips > 0
241
+ ? { "data-placeholder-height-twips": String(heightTwips) }
242
+ : {}),
243
+ style: `${heightStyle}; contain: strict;`,
225
244
  "aria-hidden": "true",
226
245
  },
227
246
  0,
@@ -867,10 +867,25 @@ function buildOpaqueBlock(
867
867
  const placeholderSize = block.placeholderSize ?? null;
868
868
  if (placeholderSize !== null) {
869
869
  const targetSize = placeholderSize as number;
870
+ // Flicker fix — when DocumentRuntime has enriched the placeholder with
871
+ // the block's known rendered height (from L04's page graph), thread it
872
+ // onto the paragraph node so `pm-schema.ts::toDOM` emits a fixed
873
+ // `height` style matching the real block. Without this, the placeholder
874
+ // renders at `min-height: 20px` and inflates to its real height when
875
+ // the block realizes on scroll, dragging content below the scroll
876
+ // pointer ("paragraphs jump around pagination gaps").
877
+ const placeholderHeightTwips = block.placeholderHeightTwips ?? null;
878
+ const placeholderAttrs: Record<string, unknown> = {
879
+ blockId: block.blockId,
880
+ placeholderCulled: true,
881
+ };
882
+ if (placeholderHeightTwips !== null) {
883
+ placeholderAttrs.placeholderHeightTwips = placeholderHeightTwips;
884
+ }
870
885
  if (targetSize <= 2) {
871
886
  // Edge case: bare empty paragraph claims exactly 2 positions.
872
887
  return editorSchema.nodes.paragraph.create(
873
- { blockId: block.blockId, placeholderCulled: true },
888
+ placeholderAttrs,
874
889
  Fragment.empty,
875
890
  );
876
891
  }
@@ -878,7 +893,7 @@ function buildOpaqueBlock(
878
893
  // total PM positions = 1 (open) + (targetSize - 2) (text) + 1 (close) = targetSize.
879
894
  const filler = "\u200b".repeat(targetSize - 2);
880
895
  return editorSchema.nodes.paragraph.create(
881
- { blockId: block.blockId, placeholderCulled: true },
896
+ placeholderAttrs,
882
897
  editorSchema.text(filler),
883
898
  );
884
899
  }
@@ -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 · the single funnel for snapshot replacement.
919
+ // Wave 1 Slice C · snapshot-replacement funnel.
920
920
  //
921
- // `replaceStatePreservingPosition` encapsulates two invariants:
922
- // 1. Scroll position preservation capture the anchor block
923
- // before `view.updateState`, restore scroll after, so the
924
- // user's viewport doesn't jump when blocks above change
925
- // height (invariant 7: geometry-facet warm path, no DOM
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
- // Ordering is regression-guarded by
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;