@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.
- package/package.json +1 -1
- package/src/api/anchor-conversion.ts +2 -2
- package/src/api/public-types.ts +33 -6
- package/src/api/v3/_runtime-handle.ts +15 -0
- package/src/api/v3/ui/_types.ts +21 -0
- package/src/api/v3/ui/overlays.ts +276 -2
- package/src/api/v3/ui/scope.ts +113 -1
- package/src/compare/diff-engine.ts +1 -2
- package/src/core/commands/index.ts +14 -15
- package/src/core/selection/anchor-conversion.ts +2 -2
- package/src/core/selection/mapping.ts +10 -8
- package/src/core/selection/review-anchors.ts +3 -3
- package/src/io/export/export-session.ts +53 -0
- package/src/io/export/serialize-comments.ts +4 -4
- package/src/io/export/serialize-runtime-revisions.ts +10 -10
- package/src/io/export/split-review-boundaries.ts +4 -4
- package/src/io/export/split-story-blocks-for-runtime-revisions.ts +2 -2
- package/src/io/ooxml/parse-comments.ts +2 -2
- package/src/model/anchor.ts +9 -1
- package/src/model/canonical-document.ts +76 -3
- package/src/preservation/store.ts +24 -0
- package/src/review/store/comment-anchors.ts +1 -1
- package/src/review/store/comment-remapping.ts +1 -1
- package/src/review/store/revision-actions.ts +4 -4
- package/src/review/store/revision-types.ts +1 -1
- package/src/review/store/scope-tag-diff.ts +1 -1
- package/src/runtime/collab/map-local-selection-on-remote-replay.ts +7 -7
- package/src/runtime/document-runtime.ts +205 -37
- package/src/runtime/formatting/formatting-context.ts +1 -1
- package/src/runtime/layout/inert-layout-facet.ts +1 -0
- package/src/runtime/layout/layout-engine-version.ts +9 -1
- package/src/runtime/layout/public-facet.ts +27 -0
- package/src/runtime/scopes/evidence.ts +1 -1
- package/src/runtime/scopes/review-bundle.ts +1 -1
- package/src/runtime/scopes/scope-range.ts +1 -1
- package/src/runtime/selection/post-edit-validator.ts +4 -4
- package/src/runtime/surface-projection.ts +39 -4
- package/src/session/import/review-import.ts +12 -12
- package/src/session/import/workflow-scope-import.ts +9 -8
- package/src/shell/session-bootstrap.ts +4 -0
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +5 -2
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +99 -43
- package/src/ui-tailwind/review-workspace/use-page-markers.ts +48 -7
- package/src/ui-tailwind/review-workspace/use-workspace-side-effects.ts +8 -8
- package/src/ui-tailwind/tw-review-workspace.tsx +13 -35
- package/src/validation/compatibility-engine.ts +1 -1
- package/src/ui-tailwind/chrome/tw-layout-panel.tsx +0 -114
- 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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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:
|
|
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:
|
|
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.
|
|
244
|
-
to: anchor.
|
|
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
|
|
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
|
-
|
|
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
|
|
15
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* the
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
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.
|
|
157
|
+
layoutFacet.setVisibleBlockRanges(visibleBlockRanges);
|
|
126
158
|
layoutFacet.requestViewportRefresh();
|
|
127
|
-
|
|
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 {
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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.
|
|
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
|
-
}
|