@beyondwork/docx-react-component 1.0.73 → 1.0.74

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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/src/api/anchor-conversion.ts +2 -2
  3. package/src/api/public-types.ts +33 -6
  4. package/src/api/v3/_runtime-handle.ts +15 -0
  5. package/src/api/v3/ui/_types.ts +21 -0
  6. package/src/api/v3/ui/overlays.ts +276 -2
  7. package/src/api/v3/ui/scope.ts +113 -1
  8. package/src/compare/diff-engine.ts +1 -2
  9. package/src/core/commands/index.ts +14 -15
  10. package/src/core/selection/anchor-conversion.ts +2 -2
  11. package/src/core/selection/mapping.ts +10 -8
  12. package/src/core/selection/review-anchors.ts +3 -3
  13. package/src/io/export/export-session.ts +53 -0
  14. package/src/io/export/serialize-comments.ts +4 -4
  15. package/src/io/export/serialize-runtime-revisions.ts +10 -10
  16. package/src/io/export/split-review-boundaries.ts +4 -4
  17. package/src/io/export/split-story-blocks-for-runtime-revisions.ts +2 -2
  18. package/src/io/ooxml/parse-comments.ts +2 -2
  19. package/src/model/anchor.ts +9 -1
  20. package/src/model/canonical-document.ts +76 -3
  21. package/src/preservation/store.ts +24 -0
  22. package/src/review/store/comment-anchors.ts +1 -1
  23. package/src/review/store/comment-remapping.ts +1 -1
  24. package/src/review/store/revision-actions.ts +4 -4
  25. package/src/review/store/revision-types.ts +1 -1
  26. package/src/review/store/scope-tag-diff.ts +1 -1
  27. package/src/runtime/collab/map-local-selection-on-remote-replay.ts +7 -7
  28. package/src/runtime/document-runtime.ts +205 -37
  29. package/src/runtime/formatting/formatting-context.ts +1 -1
  30. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  31. package/src/runtime/layout/layout-engine-version.ts +9 -1
  32. package/src/runtime/layout/public-facet.ts +27 -0
  33. package/src/runtime/scopes/evidence.ts +1 -1
  34. package/src/runtime/scopes/review-bundle.ts +1 -1
  35. package/src/runtime/scopes/scope-range.ts +1 -1
  36. package/src/runtime/selection/post-edit-validator.ts +4 -4
  37. package/src/runtime/surface-projection.ts +39 -4
  38. package/src/session/import/review-import.ts +12 -12
  39. package/src/session/import/workflow-scope-import.ts +9 -8
  40. package/src/shell/session-bootstrap.ts +4 -0
  41. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +5 -2
  42. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +99 -43
  43. package/src/ui-tailwind/review-workspace/use-page-markers.ts +48 -7
  44. package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +8 -8
  45. package/src/ui-tailwind/tw-review-workspace.tsx +13 -35
  46. package/src/validation/compatibility-engine.ts +1 -1
  47. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +0 -114
  48. package/src/ui-tailwind/review-workspace/tw-review-workspace-page-toolbar.tsx +0 -240
@@ -6,12 +6,13 @@
6
6
  *
7
7
  * P6-clean: `CommentThread` reaches through `src/model/review/`; every
8
8
  * workflow type comes from `src/api/public-types.ts` (an allowed surface
9
- * for the session layer). Anchor-shape conversion reaches through the
10
- * shared boundary helper at `src/api/anchor-conversion.ts` (relocated in
11
- * slice 5e-6g).
9
+ * for the session layer). Anchor-shape conversion is no longer needed —
10
+ * L02's FLAT WINS collapse (2026-04-24, `5b2f6f56`) made `CanonicalAnchor`,
11
+ * `InternalEditorAnchorProjection`, and the public `EditorAnchorProjection`
12
+ * structurally identical, so `thread.anchor` flows directly into the
13
+ * `WorkflowScope.anchor` slot without a helper call.
12
14
  */
13
15
 
14
- import { toPublicAnchorProjection } from "../../api/anchor-conversion.ts";
15
16
  import type {
16
17
  WorkflowMetadataSnapshot,
17
18
  WorkflowOverlay,
@@ -155,7 +156,7 @@ export function createClmWorkflowScope(
155
156
  scopeId,
156
157
  version,
157
158
  mode: directive.mode,
158
- anchor: toPublicAnchorProjection(thread.anchor),
159
+ anchor: thread.anchor,
159
160
  storyTarget: { kind: "main" },
160
161
  workItemId,
161
162
  label: directive.description,
@@ -166,7 +167,7 @@ export function createClmWorkflowScope(
166
167
  scopeId,
167
168
  version,
168
169
  mode: directive.mode,
169
- anchor: toPublicAnchorProjection(thread.anchor),
170
+ anchor: thread.anchor,
170
171
  storyTarget: { kind: "main" },
171
172
  label: directive.description,
172
173
  metadata: createClmScopeMetadata(directive),
@@ -240,8 +241,8 @@ export function getNextClmScopeVersion(
240
241
  anchor: Extract<CommentThread["anchor"], { kind: "range" }>,
241
242
  ): number {
242
243
  const anchorRange = {
243
- from: anchor.range.from,
244
- to: anchor.range.to,
244
+ from: anchor.from,
245
+ to: anchor.to,
245
246
  };
246
247
  const overlappingVersions = scopes.flatMap((scope) => {
247
248
  if (scope.anchor.kind !== "range") {
@@ -1139,6 +1139,9 @@ function createLoadingRuntimeBridge(input: {
1139
1139
  },
1140
1140
  getScope: () => null,
1141
1141
  compileScopeBundleById: () => null,
1142
+ compileScopeList: () => [],
1143
+ compileScopeCardById: () => null,
1144
+ compileScopeRailSnapshot: () => ({ segments: [] }),
1142
1145
  getMarkerBackedScopeIds: () => new Set<string>(),
1143
1146
  debug: createLoadingDebugFacet(),
1144
1147
  removeScope: () => undefined,
@@ -1281,6 +1284,7 @@ function createLoadingRuntimeBridge(input: {
1281
1284
  getPerfCountersSnapshot: () => ({}),
1282
1285
  resetPerfCounters: () => undefined,
1283
1286
  setVisibleBlockRange: () => undefined,
1287
+ setVisibleBlockRanges: () => undefined,
1284
1288
  requestViewportRefresh: () => undefined,
1285
1289
  addInvisibleScope: () => ({ scopeId: "", anchor: { kind: "range", from: 0, to: 0, assoc: { start: -1, end: 1 } } }),
1286
1290
  setScopeVisibility: () => undefined,
@@ -24,7 +24,7 @@ export function createSurfaceDocumentBuildKey(input: {
24
24
  showUnsupportedObjectPreviews?: boolean;
25
25
  isPageWorkspace?: boolean;
26
26
  }): string {
27
- const vp = input.surface?.viewportBlockRange ?? null;
27
+ const ranges = input.surface?.viewportBlockRanges ?? null;
28
28
  return JSON.stringify({
29
29
  surfaceIdentity:
30
30
  input.surface === undefined || input.surface === null
@@ -34,7 +34,10 @@ export function createSurfaceDocumentBuildKey(input: {
34
34
  mediaPreviewKey: input.mediaPreviewKey,
35
35
  showUnsupportedObjectPreviews: input.showUnsupportedObjectPreviews ?? false,
36
36
  isPageWorkspace: input.isPageWorkspace ?? false,
37
- viewport: vp ? `${vp.start}:${vp.end}` : "full",
37
+ // Serialize all intervals (sorted by start) disjoint viewport+caret
38
+ // ranges must key distinctly from a single merged range so PM rebuilds
39
+ // when the caret's page comes into/out of the realized set.
40
+ viewport: ranges ? ranges.map((r) => `${r.start}:${r.end}`).join("|") : "full",
38
41
  });
39
42
  }
40
43
 
@@ -11,19 +11,29 @@ import * as React from "react";
11
11
  const ESTIMATED_BLOCKS_PER_PAGE_FALLBACK = 50;
12
12
 
13
13
  /**
14
- * Block-range hook — returns the range of surface block indices that should
15
- * be rendered in PM as "real" (non-placeholder) blocks.
14
+ * Block-range hook — returns the block-index intervals that should be
15
+ * rendered in PM as "real" (non-placeholder) blocks.
16
16
  *
17
17
  * Sources of truth:
18
18
  * 1. IntersectionObserver on `[data-page-frame]` markers in the PM DOM.
19
19
  * 2. Selection head block-index — always included (selection-guard).
20
20
  * 3. Overscan — ±N pages around the visible set to avoid jank when scrolling.
21
21
  *
22
- * If the selection is far off-screen, the returned range spans both the
23
- * visible window AND the selection's page (with the gap between filled in).
24
- * Gap-filling is a deliberate correctness choice: position preservation does
25
- * NOT require continuous viewport coverage, but continuous coverage simplifies
26
- * the snapshot-projection step downstream.
22
+ * Contract: the hook returns a **list of disjoint intervals**, not a single
23
+ * contiguous range. When the caret is far off-screen the hook emits the
24
+ * viewport window AND the caret's page as two separate intervals rather
25
+ * than merging them into one; this keeps the gap between them virtualized
26
+ * (placeholder-culled) instead of being forced to realize the full gap as
27
+ * real blocks. Earlier implementations merged them for projection
28
+ * simplicity; on long documents with an off-screen caret that quietly
29
+ * defeated virtualization and dominated steady-state scroll cost. See
30
+ * `docs/wiki/performance.md` §"Viewport realization".
31
+ *
32
+ * `useVisibleBlockRanges` is the canonical multi-interval hook.
33
+ * `useVisibleBlockRange` is the legacy single-range helper that returns
34
+ * the bounding hull of whatever `useVisibleBlockRanges` would have emitted
35
+ * — still available for call sites that really need a scalar (typically
36
+ * none; most callers should switch to the multi-range form).
27
37
  */
28
38
  export interface VisibleBlockRangeInput {
29
39
  pageMarkers: readonly HTMLElement[];
@@ -57,7 +67,14 @@ function readBlockIndex(el: HTMLElement, attr: string): number | null {
57
67
  return Number.isFinite(n) ? n : null;
58
68
  }
59
69
 
60
- export function useVisibleBlockRange(input: VisibleBlockRangeInput): BlockRange {
70
+ /**
71
+ * Multi-interval hook. Returns the list of non-overlapping block-index
72
+ * intervals that should be real in the surface projection. Typical output:
73
+ * a single interval covering viewport + overscan. When the caret's page is
74
+ * outside that interval, a second interval covering just the caret's page
75
+ * is appended, leaving the gap between the two virtualized.
76
+ */
77
+ export function useVisibleBlockRanges(input: VisibleBlockRangeInput): readonly BlockRange[] {
61
78
  const { pageMarkers, overscanPages, selectionBlockIndex, totalBlockCount } = input;
62
79
  const [visiblePages, setVisiblePages] = React.useState<Set<number>>(() => new Set());
63
80
 
@@ -101,11 +118,11 @@ export function useVisibleBlockRange(input: VisibleBlockRangeInput): BlockRange
101
118
  }, [pageMarkers]);
102
119
 
103
120
  return React.useMemo(() => {
104
- if (totalBlockCount <= 0) return { start: 0, end: 0 };
121
+ if (totalBlockCount <= 0) return [];
105
122
  if (visiblePages.size === 0 && selectionBlockIndex === null) {
106
123
  // No visibility signal yet — return initial-load window (first 2 × overscanPages worth).
107
124
  const initialEnd = Math.min(totalBlockCount, (overscanPages * 2 + 1) * ESTIMATED_BLOCKS_PER_PAGE_FALLBACK);
108
- return { start: 0, end: initialEnd };
125
+ return initialEnd > 0 ? [{ start: 0, end: initialEnd }] : [];
109
126
  }
110
127
 
111
128
  // Expand visiblePages by ±overscanPages.
@@ -130,45 +147,84 @@ export function useVisibleBlockRange(input: VisibleBlockRangeInput): BlockRange
130
147
  maxBlock = Math.min(totalBlockCount, (overscanPages * 2 + 1) * ESTIMATED_BLOCKS_PER_PAGE_FALLBACK);
131
148
  }
132
149
 
133
- // Selection-guard: if selection is outside [minBlock, maxBlock), extend to cover
134
- // the entire page that contains the selection.
135
- if (selectionBlockIndex !== null) {
136
- if (selectionBlockIndex < minBlock) {
137
- // Find the page that contains selectionBlockIndex and extend to its start.
138
- for (const marker of pageMarkers) {
139
- const first = readBlockIndex(marker, "data-page-first-block-index");
140
- const last = readBlockIndex(marker, "data-page-last-block-index");
141
- if (first !== null && last !== null && selectionBlockIndex >= first && selectionBlockIndex <= last) {
142
- if (first < minBlock) minBlock = first;
143
- break;
144
- }
145
- }
146
- // Fallback: just include the block itself.
147
- if (selectionBlockIndex < minBlock) minBlock = selectionBlockIndex;
148
- }
149
- if (selectionBlockIndex >= maxBlock) {
150
- // Find the page that contains selectionBlockIndex and extend to its end.
151
- for (const marker of pageMarkers) {
152
- const first = readBlockIndex(marker, "data-page-first-block-index");
153
- const last = readBlockIndex(marker, "data-page-last-block-index");
154
- if (first !== null && last !== null && selectionBlockIndex >= first && selectionBlockIndex <= last) {
155
- if (last + 1 > maxBlock) maxBlock = last + 1;
156
- break;
157
- }
158
- }
159
- // Fallback: just include the block itself.
160
- if (selectionBlockIndex >= maxBlock) maxBlock = selectionBlockIndex + 1;
161
- }
162
- }
163
-
164
- // Clamp to doc bounds.
165
- return {
150
+ const viewportRange: BlockRange = {
166
151
  start: Math.max(0, minBlock),
167
152
  end: Math.min(totalBlockCount, maxBlock),
168
153
  };
154
+
155
+ // Selection-guard: when the selection is inside viewportRange, it's
156
+ // already realized — nothing to do. When it's outside, emit the
157
+ // caret's page as a SECOND interval instead of extending viewportRange
158
+ // to cover the gap. On long documents with the caret far from the
159
+ // viewport this avoids realizing the entire gap as real blocks (the
160
+ // pre-fix behavior that dominated steady-state scroll cost).
161
+ if (
162
+ selectionBlockIndex === null ||
163
+ (selectionBlockIndex >= viewportRange.start &&
164
+ selectionBlockIndex < viewportRange.end)
165
+ ) {
166
+ return viewportRange.end > viewportRange.start ? [viewportRange] : [];
167
+ }
168
+
169
+ // Resolve the page that contains the selection.
170
+ let selStart = selectionBlockIndex;
171
+ let selEnd = selectionBlockIndex + 1;
172
+ for (const marker of pageMarkers) {
173
+ const first = readBlockIndex(marker, "data-page-first-block-index");
174
+ const last = readBlockIndex(marker, "data-page-last-block-index");
175
+ if (
176
+ first !== null &&
177
+ last !== null &&
178
+ selectionBlockIndex >= first &&
179
+ selectionBlockIndex <= last
180
+ ) {
181
+ selStart = first;
182
+ selEnd = last + 1;
183
+ break;
184
+ }
185
+ }
186
+ const selectionRange: BlockRange = {
187
+ start: Math.max(0, selStart),
188
+ end: Math.min(totalBlockCount, selEnd),
189
+ };
190
+ if (selectionRange.end <= selectionRange.start) {
191
+ return viewportRange.end > viewportRange.start ? [viewportRange] : [];
192
+ }
193
+
194
+ // Sort ascending by `start` so the facet layer sees a canonical order
195
+ // and can dedupe with a simple serialization. Runtime's
196
+ // `normalizeViewportRanges` merges touching/overlapping intervals, so
197
+ // we don't need to handle that here.
198
+ const ranges: BlockRange[] =
199
+ selectionRange.start < viewportRange.start
200
+ ? [selectionRange, viewportRange]
201
+ : [viewportRange, selectionRange];
202
+ return ranges;
169
203
  }, [visiblePages, selectionBlockIndex, pageMarkers, overscanPages, totalBlockCount]);
170
204
  }
171
205
 
206
+ /**
207
+ * Legacy single-range wrapper. Returns the bounding hull of the multi-range
208
+ * result. Prefer {@link useVisibleBlockRanges} — this wrapper widens the
209
+ * realized window back to one contiguous interval and reintroduces the
210
+ * scroll-cost regression it was refactored to eliminate.
211
+ *
212
+ * @deprecated use {@link useVisibleBlockRanges}.
213
+ */
214
+ export function useVisibleBlockRange(input: VisibleBlockRangeInput): BlockRange {
215
+ const ranges = useVisibleBlockRanges(input);
216
+ return React.useMemo(() => {
217
+ if (ranges.length === 0) return { start: 0, end: 0 };
218
+ let start = ranges[0]!.start;
219
+ let end = ranges[0]!.end;
220
+ for (let i = 1; i < ranges.length; i += 1) {
221
+ if (ranges[i]!.start < start) start = ranges[i]!.start;
222
+ if (ranges[i]!.end > end) end = ranges[i]!.end;
223
+ }
224
+ return { start, end };
225
+ }, [ranges]);
226
+ }
227
+
172
228
  /**
173
229
  * L7 Phase 2.8 — sibling hook returning the visible sequential page index
174
230
  * range for chrome-overlay viewport culling. Reuses the same
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react";
3
3
  import type { RuntimeRenderSnapshot } from "../../api/public-types.ts";
4
4
  import {
5
5
  useVisibleBlockRange,
6
+ useVisibleBlockRanges,
6
7
  useVisiblePageIndexRange,
7
8
  } from "../page-stack/use-visible-block-range.ts";
8
9
 
@@ -14,7 +15,15 @@ export interface UsePageMarkersOptions {
14
15
 
15
16
  export interface PageMarkersResult {
16
17
  pageMarkers: readonly HTMLElement[];
18
+ /**
19
+ * Bounding hull of {@link visibleBlockRanges}. Kept on the result for
20
+ * back-compat with call sites that only consumed a single range; the
21
+ * runtime now receives the disjoint ranges directly via
22
+ * `setVisibleBlockRanges`.
23
+ */
17
24
  visibleBlockRange: ReturnType<typeof useVisibleBlockRange>;
25
+ /** The canonical multi-interval realization set. */
26
+ visibleBlockRanges: ReturnType<typeof useVisibleBlockRanges>;
18
27
  visiblePageIndexRange: ReturnType<typeof useVisiblePageIndexRange>;
19
28
  }
20
29
 
@@ -99,13 +108,26 @@ export function usePageMarkers(options: UsePageMarkersOptions): PageMarkersResul
99
108
  return null;
100
109
  }, [snapshot.selection, snapshot.surface]);
101
110
 
102
- const visibleBlockRange = useVisibleBlockRange({
111
+ const visibleBlockRanges = useVisibleBlockRanges({
103
112
  pageMarkers,
104
113
  overscanPages: 2,
105
114
  selectionBlockIndex,
106
115
  totalBlockCount: snapshot.surface?.blocks.length ?? 0,
107
116
  });
108
117
 
118
+ // Bounding hull of the disjoint ranges for the back-compat `visibleBlockRange`
119
+ // result field + for the effect's dep key (below).
120
+ const visibleBlockRange = useMemo(() => {
121
+ if (visibleBlockRanges.length === 0) return { start: 0, end: 0 };
122
+ let start = visibleBlockRanges[0]!.start;
123
+ let end = visibleBlockRanges[0]!.end;
124
+ for (let i = 1; i < visibleBlockRanges.length; i += 1) {
125
+ if (visibleBlockRanges[i]!.start < start) start = visibleBlockRanges[i]!.start;
126
+ if (visibleBlockRanges[i]!.end > end) end = visibleBlockRanges[i]!.end;
127
+ }
128
+ return { start, end };
129
+ }, [visibleBlockRanges]);
130
+
109
131
  // L7 Phase 2.8 — viewport cull for `TwPageStackChromeLayer`. Returns
110
132
  // `null` while the IntersectionObserver hasn't reported yet; the chrome
111
133
  // layer treats null as "render every page" so first paint is
@@ -117,14 +139,33 @@ export function usePageMarkers(options: UsePageMarkersOptions): PageMarkersResul
117
139
  overscanPages: 2,
118
140
  });
119
141
 
120
- // Push the visible range into the layout facet (which delegates to the
121
- // runtime's viewport-culling machinery). Depend on `[start, end]` values
122
- // (not the range object) so identity-preserving updates are a no-op.
142
+ // Stable fingerprint of the current disjoint-range set; used as the effect
143
+ // dep so identity-preserving recomputes (same intervals, new memo array
144
+ // reference) don't re-fire the viewport refresh.
145
+ const visibleRangesKey = useMemo(
146
+ () => visibleBlockRanges.map((r) => `${r.start}:${r.end}`).join("|"),
147
+ [visibleBlockRanges],
148
+ );
149
+
150
+ // Push the visible ranges into the layout facet (which delegates to the
151
+ // runtime's viewport-culling machinery). When the caret is off-screen
152
+ // this emits two disjoint intervals; when on-screen, one. Runtime
153
+ // `normalizeViewportRanges` merges touching intervals, so we don't have
154
+ // to worry about minor overlap when overscan and selection-page touch.
123
155
  useEffect(() => {
124
156
  if (!layoutFacet) return;
125
- layoutFacet.setVisibleBlockRange(visibleBlockRange);
157
+ layoutFacet.setVisibleBlockRanges(visibleBlockRanges);
126
158
  layoutFacet.requestViewportRefresh();
127
- }, [layoutFacet, visibleBlockRange.start, visibleBlockRange.end]);
159
+ // visibleRangesKey captures the structural change; visibleBlockRanges
160
+ // identity alone is not a stable dep because the hook re-allocates on
161
+ // each render even when intervals are unchanged.
162
+ // eslint-disable-next-line react-hooks/exhaustive-deps
163
+ }, [layoutFacet, visibleRangesKey]);
128
164
 
129
- return { pageMarkers, visibleBlockRange, visiblePageIndexRange };
165
+ return {
166
+ pageMarkers,
167
+ visibleBlockRange,
168
+ visibleBlockRanges,
169
+ visiblePageIndexRange,
170
+ };
130
171
  }
@@ -19,7 +19,6 @@ export interface UseWorkspaceSideEffectsOptions {
19
19
  pageShellMetrics: PageShellMetrics;
20
20
  isPageWorkspace: boolean;
21
21
  activeStoryKind: RuntimeRenderSnapshot["activeStory"]["kind"];
22
- setLayoutToolsOpen: Dispatch<SetStateAction<boolean>>;
23
22
  showDrawerReviewRail: boolean;
24
23
  setReviewRailOpen: Dispatch<SetStateAction<boolean>>;
25
24
  onOpenHeaderStory?: () => void;
@@ -61,7 +60,6 @@ export function useWorkspaceSideEffects(
61
60
  pageShellMetrics,
62
61
  isPageWorkspace,
63
62
  activeStoryKind,
64
- setLayoutToolsOpen,
65
63
  showDrawerReviewRail,
66
64
  setReviewRailOpen,
67
65
  onOpenHeaderStory,
@@ -69,17 +67,19 @@ export function useWorkspaceSideEffects(
69
67
  onDismissSelectionToolbar,
70
68
  } = options;
71
69
 
70
+ // Slice A (designsystem §6.20 reshape, 2026-04-24): isPageWorkspace +
71
+ // activeStoryKind referenced here so the prop sweep stays a no-op
72
+ // type-checker pass even though the auto-open-layout-tools effect
73
+ // they fed retired with the strip. Slice B mounts an active-band
74
+ // ribbon that observes activeStoryKind directly.
75
+ void isPageWorkspace;
76
+ void activeStoryKind;
77
+
72
78
  useEffect(() => {
73
79
  recordPerfSample("workspace.chrome");
74
80
  incrementInvalidationCounter("workspace.chrome.recomputes");
75
81
  }, [activeParagraphLayout, pageChromeModel, pageShellMetrics]);
76
82
 
77
- useEffect(() => {
78
- if (isPageWorkspace && activeStoryKind !== "main") {
79
- setLayoutToolsOpen(true);
80
- }
81
- }, [isPageWorkspace, activeStoryKind, setLayoutToolsOpen]);
82
-
83
83
  // P8.11 — deprecation shim for the legacy `onOpenHeaderStory` /
84
84
  // `onOpenFooterStory` props. Per-page chrome bands route clicks via
85
85
  // `onOpenStory` + `runtime.openStory` directly; the workspace-level
@@ -25,7 +25,6 @@ import { shouldRenderSelectionToolKind } from "../ui/headless/scoped-chrome-poli
25
25
  import type { EditorCommandBag } from "../ui/editor-command-bag.ts";
26
26
  import { preserveEditorSelectionMouseDown } from "../ui/headless/preserve-editor-selection";
27
27
  import { TwAlertBanner } from "./chrome/tw-alert-banner";
28
- import { TwLayoutPanel } from "./chrome/tw-layout-panel";
29
28
  import { TwPageRuler } from "./chrome/tw-page-ruler";
30
29
  import { ChromePresetToolbar } from "./chrome/chrome-preset-toolbar";
31
30
  import {
@@ -70,7 +69,6 @@ import { useLayoutFacetRenderSignal } from "./review-workspace/use-layout-facet-
70
69
  import { useScrollRootCapture } from "./review-workspace/use-scroll-root-capture.ts";
71
70
  import { usePmSurfaceCapture } from "./review-workspace/use-pm-surface-capture.ts";
72
71
  import { TwReviewWorkspaceNavigator } from "./review-workspace/tw-review-workspace-navigator.tsx";
73
- import { TwReviewWorkspacePageToolbar } from "./review-workspace/tw-review-workspace-page-toolbar.tsx";
74
72
 
75
73
  export {
76
74
  FRAME_PX_PER_TWIP_AT_96DPI,
@@ -145,7 +143,6 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
145
143
  const isPageWorkspace = props.workspaceMode === "page";
146
144
  const markupDisplay = props.markupDisplay;
147
145
  const [navOpen, setNavOpen] = useState(false);
148
- const [layoutToolsOpen, setLayoutToolsOpen] = useState(false);
149
146
 
150
147
  // Scope card state — tracks which scope's card is currently open so
151
148
  // the ChromeOverlay's card layer renders the right one. Open/close
@@ -227,8 +224,12 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
227
224
  viewState.selection.activeRange.kind === "node"
228
225
  ? viewState.selection.activeRange.at
229
226
  : viewState.selection.head;
230
- const shouldResolveActiveParagraphLayout =
231
- isPageWorkspace && chromeVisibility.pageChrome && layoutToolsOpen;
227
+ // Slice A (designsystem §6.20 reshape): the legacy "Layout tools"
228
+ // disclosure that gated this resolver is gone. Slice B (active-band
229
+ // ribbon) re-enables resolution when a header/footer band is the
230
+ // active story; until then, no consumer reads activeParagraphLayout
231
+ // so the resolver stays inert.
232
+ const shouldResolveActiveParagraphLayout = false;
232
233
  const effectiveSelectionMode = props.interactionGuardSnapshot?.effectiveMode ?? "edit";
233
234
  const allowLocalChromeMutations = Boolean(caps?.canEdit) && effectiveSelectionMode === "edit";
234
235
  const {
@@ -331,7 +332,6 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
331
332
  pageShellMetrics,
332
333
  isPageWorkspace,
333
334
  activeStoryKind: snapshot.activeStory.kind,
334
- setLayoutToolsOpen,
335
335
  showDrawerReviewRail: responsiveChrome.showDrawerReviewRail,
336
336
  setReviewRailOpen,
337
337
  onOpenHeaderStory: props.onOpenHeaderStory,
@@ -778,35 +778,13 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
778
778
  data-workspace-canvas={isPageWorkspace ? "true" : undefined}
779
779
  data-workspace-mode={isPageWorkspace ? "page" : "canvas"}
780
780
  >
781
- <TwReviewWorkspacePageToolbar
782
- enabled={isPageWorkspace && chromeVisibility.pageChrome && Boolean(snapshot.pageLayout)}
783
- pageLayout={snapshot.pageLayout}
784
- activeStory={snapshot.activeStory}
785
- activePage={activePage}
786
- pageCount={props.documentNavigation?.pageCount ?? 1}
787
- headerVariant={headerVariant}
788
- footerVariant={footerVariant}
789
- allowLocalChromeMutations={allowLocalChromeMutations}
790
- pageChromeReadOnly={pageChromeReadOnly}
791
- layoutToolsOpen={layoutToolsOpen}
792
- setLayoutToolsOpen={setLayoutToolsOpen}
793
- viewState={viewState}
794
- activeParagraphLayout={activeParagraphLayout}
795
- dismissSelectionToolbar={dismissSelectionToolbar}
796
- runWithSelectionToolbarDismiss={runWithSelectionToolbarDismiss}
797
- {...(props.onCloseStory ? { onCloseStory: props.onCloseStory } : {})}
798
- {...(props.onOpenHeaderStory ? { onOpenHeaderStory: props.onOpenHeaderStory } : {})}
799
- {...(props.onOpenFooterStory ? { onOpenFooterStory: props.onOpenFooterStory } : {})}
800
- {...(props.onSetHeaderFooterLink ? { onSetHeaderFooterLink: props.onSetHeaderFooterLink } : {})}
801
- {...(props.onSetParagraphIndentation ? { onSetParagraphIndentation: props.onSetParagraphIndentation } : {})}
802
- {...(props.onSetParagraphTabStops ? { onSetParagraphTabStops: props.onSetParagraphTabStops } : {})}
803
- {...(props.onRestartNumbering ? { onRestartNumbering: props.onRestartNumbering } : {})}
804
- {...(props.onContinueNumbering ? { onContinueNumbering: props.onContinueNumbering } : {})}
805
- {...(props.onInsertSectionBreak ? { onInsertSectionBreak: props.onInsertSectionBreak } : {})}
806
- {...(props.onDeleteSectionBreak ? { onDeleteSectionBreak: props.onDeleteSectionBreak } : {})}
807
- {...(props.onUpdateSectionLayout ? { onUpdateSectionLayout: props.onUpdateSectionLayout } : {})}
808
- {...(props.onSetSectionPageNumbering ? { onSetSectionPageNumbering: props.onSetSectionPageNumbering } : {})}
809
- />
781
+ {/*
782
+ * Slice A (designsystem §6.20 reshape, 2026-04-24):
783
+ * the legacy "Page N of M · Section N · orientation
784
+ * · Layout tools" strip mounted here is gone;
785
+ * section-properties controls move to the on-demand
786
+ * band-activation ribbon (Slice B).
787
+ */}
810
788
  {chromeVisibility.selectionOverlay &&
811
789
  gatedSelectionTool &&
812
790
  shouldRenderSelectionToolKind(scopedChromePolicy, gatedSelectionTool.kind) ? (
@@ -848,7 +848,7 @@ function toRevisionAffectedAnchor(
848
848
  ) {
849
849
  switch (revision.anchor.kind) {
850
850
  case "range":
851
- return createPublicRangeAnchor(revision.anchor.range.from, revision.anchor.range.to);
851
+ return createPublicRangeAnchor(revision.anchor.from, revision.anchor.to);
852
852
  case "node":
853
853
  return createPublicRangeAnchor(revision.anchor.at, revision.anchor.at + 1);
854
854
  case "detached":
@@ -1,114 +0,0 @@
1
- import React from "react";
2
-
3
- import type {
4
- PageLayoutSnapshot,
5
- SectionPageNumberingPatch,
6
- SectionBreakType,
7
- SectionLayoutPatch,
8
- } from "../../api/public-types";
9
- import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
10
-
11
- export interface TwLayoutPanelProps {
12
- pageLayout: PageLayoutSnapshot;
13
- readOnly: boolean;
14
- onInsertSectionBreak?: (type: SectionBreakType) => void;
15
- onDeleteSectionBreak?: (sectionIndex: number) => void;
16
- onUpdateSectionLayout?: (sectionIndex: number, patch: SectionLayoutPatch) => void;
17
- onSetSectionPageNumbering?: (
18
- sectionIndex: number,
19
- patch: SectionPageNumberingPatch | null,
20
- ) => void;
21
- }
22
-
23
- export function TwLayoutPanel(props: TwLayoutPanelProps) {
24
- const nextOrientation = props.pageLayout.orientation === "portrait" ? "landscape" : "portrait";
25
- const titlePageEnabled = props.pageLayout.differentFirstPage;
26
-
27
- return (
28
- <div className="mt-3 flex flex-wrap items-center gap-2 rounded-xl border border-border bg-canvas px-3 py-2 shadow-sm">
29
- <span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-tertiary">
30
- Section
31
- </span>
32
- <ToolbarButton
33
- ariaLabel="Insert next-page section break"
34
- disabled={props.readOnly || !props.onInsertSectionBreak}
35
- onClick={() => props.onInsertSectionBreak?.("nextPage")}
36
- >
37
- Next-page break
38
- </ToolbarButton>
39
- <ToolbarButton
40
- ariaLabel={`Switch section to ${nextOrientation}`}
41
- disabled={props.readOnly || !props.onUpdateSectionLayout}
42
- onClick={() =>
43
- props.onUpdateSectionLayout?.(props.pageLayout.sectionIndex, {
44
- pageSize: {
45
- orientation: nextOrientation,
46
- width: props.pageLayout.pageHeight,
47
- height: props.pageLayout.pageWidth,
48
- },
49
- })}
50
- >
51
- {nextOrientation === "landscape" ? "Landscape" : "Portrait"}
52
- </ToolbarButton>
53
- <ToolbarButton
54
- ariaLabel="Delete current section break"
55
- disabled={props.readOnly || props.pageLayout.sectionIndex === 0 || !props.onDeleteSectionBreak}
56
- onClick={() => props.onDeleteSectionBreak?.(props.pageLayout.sectionIndex)}
57
- >
58
- Delete break
59
- </ToolbarButton>
60
- <ToolbarButton
61
- ariaLabel="Restart page numbering at 1"
62
- disabled={props.readOnly || !props.onSetSectionPageNumbering}
63
- onClick={() =>
64
- props.onSetSectionPageNumbering?.(props.pageLayout.sectionIndex, {
65
- ...(props.pageLayout.pageNumbering ?? {}),
66
- start: 1,
67
- })}
68
- >
69
- Restart numbering
70
- </ToolbarButton>
71
- <ToolbarButton
72
- ariaLabel="Use roman page numbering"
73
- disabled={props.readOnly || !props.onSetSectionPageNumbering}
74
- onClick={() =>
75
- props.onSetSectionPageNumbering?.(props.pageLayout.sectionIndex, {
76
- ...(props.pageLayout.pageNumbering ?? {}),
77
- format: "roman",
78
- })}
79
- >
80
- Roman numerals
81
- </ToolbarButton>
82
- <ToolbarButton
83
- ariaLabel="Toggle different first page"
84
- disabled={props.readOnly || !props.onUpdateSectionLayout}
85
- onClick={() =>
86
- props.onUpdateSectionLayout?.(props.pageLayout.sectionIndex, {
87
- titlePage: !titlePageEnabled,
88
- })}
89
- >
90
- {titlePageEnabled ? "Same first page" : "Different first page"}
91
- </ToolbarButton>
92
- </div>
93
- );
94
- }
95
-
96
- function ToolbarButton(props: {
97
- ariaLabel: string;
98
- children: React.ReactNode;
99
- disabled: boolean;
100
- onClick?: () => void;
101
- }) {
102
- return (
103
- <button
104
- type="button"
105
- aria-label={props.ariaLabel}
106
- disabled={props.disabled}
107
- onMouseDown={preserveEditorSelectionMouseDown}
108
- onClick={props.onClick}
109
- className="inline-flex h-8 items-center rounded-md px-2 text-xs font-medium text-primary transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-40"
110
- >
111
- {props.children}
112
- </button>
113
- );
114
- }