@beyondwork/docx-react-component 1.0.81 → 1.0.83
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/public-types.ts +2 -1
- package/src/api/v3/_runtime-handle.ts +4 -0
- package/src/api/v3/runtime/document.ts +61 -3
- package/src/api/v3/runtime/review.ts +55 -2
- package/src/io/normalize/normalize-text.ts +4 -1
- package/src/io/ooxml/parse-drawing.ts +4 -0
- package/src/model/canonical-document.ts +2 -0
- package/src/ui/WordReviewEditor.tsx +243 -2
- package/src/ui-tailwind/chrome/editor-action-registry.ts +220 -0
- package/src/ui-tailwind/chrome/editor-actions-to-palette.ts +59 -35
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +256 -37
- package/src/ui-tailwind/editor-surface/pm-schema.ts +54 -1
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +31 -1
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -0
- package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +24 -5
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +35 -6
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +333 -43
- package/src/ui-tailwind/review-workspace/use-chrome-policy.ts +32 -6
- package/src/ui-tailwind/review-workspace/use-page-markers.ts +273 -24
- package/src/ui-tailwind/theme/editor-theme.css +3 -5
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +21 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +4 -0
|
@@ -70,11 +70,13 @@
|
|
|
70
70
|
import React from "react";
|
|
71
71
|
import type {
|
|
72
72
|
EditorStoryTarget,
|
|
73
|
+
GeometryFacet,
|
|
73
74
|
WordReviewEditorLayoutFacet,
|
|
74
75
|
} from "../../api/public-types.ts";
|
|
75
76
|
import {
|
|
76
77
|
measureWidgetsViaOffsetChain,
|
|
77
78
|
measureWidgetsViaBoundingRect,
|
|
79
|
+
resolvePageOverlayRectsFromGeometry,
|
|
78
80
|
resolvePageOverlayRects,
|
|
79
81
|
type PageOverlayRect,
|
|
80
82
|
} from "../chrome-overlay/tw-page-stack-overlay-layer.tsx";
|
|
@@ -97,6 +99,8 @@ export interface PmPortalView {
|
|
|
97
99
|
export interface TwPageStackChromeLayerProps {
|
|
98
100
|
/** Layout facet — source of per-page regions, stories, and blocks. */
|
|
99
101
|
facet: WordReviewEditorLayoutFacet;
|
|
102
|
+
/** Geometry facet — warm-path source of per-page frame rects. */
|
|
103
|
+
geometryFacet?: GeometryFacet;
|
|
100
104
|
/** Scroll root whose `[data-page-frame-*]` markers drive per-page rects. */
|
|
101
105
|
scrollRoot: HTMLElement | null;
|
|
102
106
|
/**
|
|
@@ -161,6 +165,7 @@ export interface TwPageStackChromeLayerProps {
|
|
|
161
165
|
|
|
162
166
|
const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
|
|
163
167
|
facet,
|
|
168
|
+
geometryFacet,
|
|
164
169
|
scrollRoot,
|
|
165
170
|
renderFrameRevision,
|
|
166
171
|
activeStory,
|
|
@@ -172,7 +177,16 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
|
|
|
172
177
|
mediaPreviews,
|
|
173
178
|
activeBandRibbonProps,
|
|
174
179
|
}) => {
|
|
175
|
-
const [rects, setRects] = React.useState<readonly PageOverlayRect[]>(
|
|
180
|
+
const [rects, setRects] = React.useState<readonly PageOverlayRect[]>(() => {
|
|
181
|
+
if (!geometryFacet) return [];
|
|
182
|
+
const pageCount = facet.getPageCount();
|
|
183
|
+
const warm = resolvePageOverlayRectsFromGeometry(
|
|
184
|
+
geometryFacet,
|
|
185
|
+
pageCount,
|
|
186
|
+
visiblePageIndexRange,
|
|
187
|
+
);
|
|
188
|
+
return warm ?? [];
|
|
189
|
+
});
|
|
176
190
|
const overlayRootRef = React.useRef<HTMLDivElement | null>(null);
|
|
177
191
|
const rafHandleRef = React.useRef<number | null>(null);
|
|
178
192
|
|
|
@@ -184,12 +198,24 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
|
|
|
184
198
|
// --------------------------------------------------------------------
|
|
185
199
|
|
|
186
200
|
const refreshRectsNow = React.useCallback(() => {
|
|
201
|
+
const pageCount = facet.getPageCount();
|
|
202
|
+
if (geometryFacet) {
|
|
203
|
+
const geometryRects = resolvePageOverlayRectsFromGeometry(
|
|
204
|
+
geometryFacet,
|
|
205
|
+
pageCount,
|
|
206
|
+
visiblePageIndexRange,
|
|
207
|
+
);
|
|
208
|
+
if (geometryRects !== null) {
|
|
209
|
+
setRects(geometryRects);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
187
214
|
if (!scrollRoot) {
|
|
188
215
|
setRects([]);
|
|
189
216
|
return;
|
|
190
217
|
}
|
|
191
218
|
const origin = overlayRootRef.current;
|
|
192
|
-
const pageCount = facet.getPageCount();
|
|
193
219
|
if (origin) {
|
|
194
220
|
const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin, {
|
|
195
221
|
pageCount,
|
|
@@ -229,11 +255,11 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
|
|
|
229
255
|
}),
|
|
230
256
|
);
|
|
231
257
|
}
|
|
232
|
-
}, [facet, scrollRoot, visiblePageIndexRange]);
|
|
258
|
+
}, [facet, geometryFacet, scrollRoot, visiblePageIndexRange]);
|
|
233
259
|
|
|
234
260
|
const refreshRects = React.useCallback(() => {
|
|
235
261
|
if (!scrollRoot) {
|
|
236
|
-
|
|
262
|
+
refreshRectsNow();
|
|
237
263
|
return;
|
|
238
264
|
}
|
|
239
265
|
const runtime = scrollRoot.ownerDocument?.defaultView as
|
|
@@ -269,6 +295,7 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
|
|
|
269
295
|
|
|
270
296
|
// Observe scroll-root size changes.
|
|
271
297
|
React.useEffect(() => {
|
|
298
|
+
if (geometryFacet) return;
|
|
272
299
|
if (!scrollRoot) return;
|
|
273
300
|
const runtime = scrollRoot.ownerDocument?.defaultView as
|
|
274
301
|
| (Window & { ResizeObserver?: typeof ResizeObserver })
|
|
@@ -277,7 +304,7 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
|
|
|
277
304
|
const observer = new runtime.ResizeObserver(() => refreshRects());
|
|
278
305
|
observer.observe(scrollRoot);
|
|
279
306
|
return () => observer.disconnect();
|
|
280
|
-
}, [scrollRoot, refreshRects]);
|
|
307
|
+
}, [geometryFacet, scrollRoot, refreshRects]);
|
|
281
308
|
|
|
282
309
|
// Observe DOM mutations on the scroll root so PM re-renders
|
|
283
310
|
// (page-break widgets added / removed) re-trigger measurement.
|
|
@@ -289,6 +316,7 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
|
|
|
289
316
|
// live inside the overlay root itself, which prevents the
|
|
290
317
|
// `setRects` → reconciliation → mutation callback loop.
|
|
291
318
|
React.useEffect(() => {
|
|
319
|
+
if (geometryFacet) return;
|
|
292
320
|
if (!scrollRoot) return;
|
|
293
321
|
const runtime = scrollRoot.ownerDocument?.defaultView as
|
|
294
322
|
| (Window & { MutationObserver?: typeof MutationObserver })
|
|
@@ -306,7 +334,7 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
|
|
|
306
334
|
});
|
|
307
335
|
observer.observe(scrollRoot, { childList: true, subtree: false });
|
|
308
336
|
return () => observer.disconnect();
|
|
309
|
-
}, [scrollRoot, refreshRects]);
|
|
337
|
+
}, [geometryFacet, scrollRoot, refreshRects]);
|
|
310
338
|
|
|
311
339
|
// --------------------------------------------------------------------
|
|
312
340
|
// P8.10 — PM portal reparent.
|
|
@@ -461,6 +489,7 @@ function propsAreEqual(
|
|
|
461
489
|
): boolean {
|
|
462
490
|
return (
|
|
463
491
|
prev.facet === next.facet &&
|
|
492
|
+
prev.geometryFacet === next.geometryFacet &&
|
|
464
493
|
prev.scrollRoot === next.scrollRoot &&
|
|
465
494
|
prev.renderFrameRevision === next.renderFrameRevision &&
|
|
466
495
|
storyTargetEqual(prev.activeStory, next.activeStory) &&
|
|
@@ -15,9 +15,10 @@ const ESTIMATED_BLOCKS_PER_PAGE_FALLBACK = 50;
|
|
|
15
15
|
* rendered in PM as "real" (non-placeholder) blocks.
|
|
16
16
|
*
|
|
17
17
|
* Sources of truth:
|
|
18
|
-
* 1.
|
|
19
|
-
* 2.
|
|
20
|
-
* 3.
|
|
18
|
+
* 1. Warm path: geometry page frames + layout page offsets.
|
|
19
|
+
* 2. Degraded path: IntersectionObserver on `[data-page-frame]` markers.
|
|
20
|
+
* 3. Selection head block-index — always included (selection-guard).
|
|
21
|
+
* 4. Overscan — ±N pages around the visible set to avoid jank when scrolling.
|
|
21
22
|
*
|
|
22
23
|
* Contract: the hook returns a **list of disjoint intervals**, not a single
|
|
23
24
|
* contiguous range. When the caret is far off-screen the hook emits the
|
|
@@ -60,6 +61,21 @@ export interface VisiblePageIndexRange {
|
|
|
60
61
|
end: number; // exclusive
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
export interface PageFrameForVisibility {
|
|
65
|
+
topPx: number;
|
|
66
|
+
heightPx: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface PageOffsetsForVisibility {
|
|
70
|
+
startOffset: number;
|
|
71
|
+
endOffset: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface BlockOffsetsForVisibility {
|
|
75
|
+
from: number;
|
|
76
|
+
to: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
function readBlockIndex(el: HTMLElement, attr: string): number | null {
|
|
64
80
|
const v = el.getAttribute(attr);
|
|
65
81
|
if (v === null) return null;
|
|
@@ -67,6 +83,309 @@ function readBlockIndex(el: HTMLElement, attr: string): number | null {
|
|
|
67
83
|
return Number.isFinite(n) ? n : null;
|
|
68
84
|
}
|
|
69
85
|
|
|
86
|
+
function clampPageRange(
|
|
87
|
+
range: VisiblePageIndexRange,
|
|
88
|
+
pageCount: number,
|
|
89
|
+
): VisiblePageIndexRange | null {
|
|
90
|
+
const start = Math.max(0, Math.min(range.start, pageCount));
|
|
91
|
+
const end = Math.max(start, Math.min(range.end, pageCount));
|
|
92
|
+
return start < end ? { start, end } : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function initialLoadRange(totalBlockCount: number, overscanPages: number): readonly BlockRange[] {
|
|
96
|
+
const initialEnd = Math.min(
|
|
97
|
+
totalBlockCount,
|
|
98
|
+
(overscanPages * 2 + 1) * ESTIMATED_BLOCKS_PER_PAGE_FALLBACK,
|
|
99
|
+
);
|
|
100
|
+
return initialEnd > 0 ? [{ start: 0, end: initialEnd }] : [];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resolveBlockRangeForPageRange(input: {
|
|
104
|
+
pageMarkers: readonly HTMLElement[];
|
|
105
|
+
visiblePageIndexRange: VisiblePageIndexRange;
|
|
106
|
+
totalBlockCount: number;
|
|
107
|
+
}): BlockRange | null {
|
|
108
|
+
const { pageMarkers, visiblePageIndexRange, totalBlockCount } = input;
|
|
109
|
+
const normalized = clampPageRange(visiblePageIndexRange, pageMarkers.length);
|
|
110
|
+
if (!normalized) return null;
|
|
111
|
+
|
|
112
|
+
let minBlock = Infinity;
|
|
113
|
+
let maxBlock = -Infinity;
|
|
114
|
+
for (let pageIndex = normalized.start; pageIndex < normalized.end; pageIndex += 1) {
|
|
115
|
+
const marker = pageMarkers[pageIndex];
|
|
116
|
+
if (!marker) continue;
|
|
117
|
+
const first = readBlockIndex(marker, "data-page-first-block-index");
|
|
118
|
+
const last = readBlockIndex(marker, "data-page-last-block-index");
|
|
119
|
+
if (first !== null) minBlock = Math.min(minBlock, first);
|
|
120
|
+
if (last !== null) maxBlock = Math.max(maxBlock, last + 1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (minBlock === Infinity) return null;
|
|
124
|
+
const range = {
|
|
125
|
+
start: Math.max(0, minBlock),
|
|
126
|
+
end: Math.min(totalBlockCount, maxBlock),
|
|
127
|
+
};
|
|
128
|
+
return range.end > range.start ? range : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function resolveSelectionRange(input: {
|
|
132
|
+
pageMarkers: readonly HTMLElement[];
|
|
133
|
+
selectionBlockIndex: number;
|
|
134
|
+
totalBlockCount: number;
|
|
135
|
+
}): BlockRange {
|
|
136
|
+
const { pageMarkers, selectionBlockIndex, totalBlockCount } = input;
|
|
137
|
+
let selStart = selectionBlockIndex;
|
|
138
|
+
let selEnd = selectionBlockIndex + 1;
|
|
139
|
+
for (const marker of pageMarkers) {
|
|
140
|
+
const first = readBlockIndex(marker, "data-page-first-block-index");
|
|
141
|
+
const last = readBlockIndex(marker, "data-page-last-block-index");
|
|
142
|
+
if (
|
|
143
|
+
first !== null &&
|
|
144
|
+
last !== null &&
|
|
145
|
+
selectionBlockIndex >= first &&
|
|
146
|
+
selectionBlockIndex <= last
|
|
147
|
+
) {
|
|
148
|
+
selStart = first;
|
|
149
|
+
selEnd = last + 1;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
start: Math.max(0, selStart),
|
|
155
|
+
end: Math.min(totalBlockCount, selEnd),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function resolveBlockRangeFromOffsetSpan(input: {
|
|
160
|
+
blocks: readonly BlockOffsetsForVisibility[];
|
|
161
|
+
startOffset: number;
|
|
162
|
+
endOffset: number;
|
|
163
|
+
totalBlockCount: number;
|
|
164
|
+
}): BlockRange | null {
|
|
165
|
+
const { blocks, startOffset, endOffset, totalBlockCount } = input;
|
|
166
|
+
if (endOffset <= startOffset) return null;
|
|
167
|
+
|
|
168
|
+
let first = -1;
|
|
169
|
+
let last = -1;
|
|
170
|
+
const scanCount = Math.min(blocks.length, totalBlockCount);
|
|
171
|
+
for (let index = 0; index < scanCount; index += 1) {
|
|
172
|
+
const block = blocks[index];
|
|
173
|
+
if (!block) continue;
|
|
174
|
+
if (block.from >= endOffset) break;
|
|
175
|
+
if (block.from >= startOffset && block.from < endOffset) {
|
|
176
|
+
if (first < 0) first = index;
|
|
177
|
+
last = index;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (first < 0 || last < first) return null;
|
|
181
|
+
return { start: first, end: last + 1 };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolveSelectionRangeFromPageOffsets(input: {
|
|
185
|
+
blocks: readonly BlockOffsetsForVisibility[];
|
|
186
|
+
pageCount: number;
|
|
187
|
+
getPageOffsets: (pageIndex: number) => PageOffsetsForVisibility | null;
|
|
188
|
+
selectionBlockIndex: number;
|
|
189
|
+
totalBlockCount: number;
|
|
190
|
+
}): BlockRange {
|
|
191
|
+
const {
|
|
192
|
+
blocks,
|
|
193
|
+
pageCount,
|
|
194
|
+
getPageOffsets,
|
|
195
|
+
selectionBlockIndex,
|
|
196
|
+
totalBlockCount,
|
|
197
|
+
} = input;
|
|
198
|
+
const selectionBlock = blocks[selectionBlockIndex];
|
|
199
|
+
if (!selectionBlock) {
|
|
200
|
+
return {
|
|
201
|
+
start: Math.max(0, selectionBlockIndex),
|
|
202
|
+
end: Math.min(totalBlockCount, selectionBlockIndex + 1),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
|
|
207
|
+
const page = getPageOffsets(pageIndex);
|
|
208
|
+
if (!page) continue;
|
|
209
|
+
if (selectionBlock.from >= page.startOffset && selectionBlock.from < page.endOffset) {
|
|
210
|
+
const pageRange = resolveBlockRangeFromOffsetSpan({
|
|
211
|
+
blocks,
|
|
212
|
+
startOffset: page.startOffset,
|
|
213
|
+
endOffset: page.endOffset,
|
|
214
|
+
totalBlockCount,
|
|
215
|
+
});
|
|
216
|
+
if (pageRange) return pageRange;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
start: Math.max(0, selectionBlockIndex),
|
|
223
|
+
end: Math.min(totalBlockCount, selectionBlockIndex + 1),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function withSelectionGuard(input: {
|
|
228
|
+
viewportRange: BlockRange;
|
|
229
|
+
selectionBlockIndex: number | null;
|
|
230
|
+
resolveSelectionRange: (selectionBlockIndex: number) => BlockRange;
|
|
231
|
+
}): readonly BlockRange[] {
|
|
232
|
+
const {
|
|
233
|
+
viewportRange,
|
|
234
|
+
selectionBlockIndex,
|
|
235
|
+
resolveSelectionRange: resolveSelectionRangeForIndex,
|
|
236
|
+
} = input;
|
|
237
|
+
if (
|
|
238
|
+
selectionBlockIndex === null ||
|
|
239
|
+
(selectionBlockIndex >= viewportRange.start && selectionBlockIndex < viewportRange.end)
|
|
240
|
+
) {
|
|
241
|
+
return viewportRange.end > viewportRange.start ? [viewportRange] : [];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const selectionRange = resolveSelectionRangeForIndex(selectionBlockIndex);
|
|
245
|
+
if (selectionRange.end <= selectionRange.start) {
|
|
246
|
+
return viewportRange.end > viewportRange.start ? [viewportRange] : [];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return selectionRange.start < viewportRange.start
|
|
250
|
+
? [selectionRange, viewportRange]
|
|
251
|
+
: [viewportRange, selectionRange];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function resolveVisibleBlockRangesFromPageRange(input: {
|
|
255
|
+
pageMarkers: readonly HTMLElement[];
|
|
256
|
+
visiblePageIndexRange: VisiblePageIndexRange;
|
|
257
|
+
selectionBlockIndex: number | null;
|
|
258
|
+
totalBlockCount: number;
|
|
259
|
+
}): readonly BlockRange[] | null {
|
|
260
|
+
if (input.totalBlockCount <= 0) return [];
|
|
261
|
+
const viewportRange = resolveBlockRangeForPageRange(input);
|
|
262
|
+
if (!viewportRange) return null;
|
|
263
|
+
return withSelectionGuard({
|
|
264
|
+
viewportRange,
|
|
265
|
+
selectionBlockIndex: input.selectionBlockIndex,
|
|
266
|
+
resolveSelectionRange: (selectionBlockIndex) =>
|
|
267
|
+
resolveSelectionRange({
|
|
268
|
+
pageMarkers: input.pageMarkers,
|
|
269
|
+
selectionBlockIndex,
|
|
270
|
+
totalBlockCount: input.totalBlockCount,
|
|
271
|
+
}),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function resolveVisibleBlockRangesFromPageOffsets(input: {
|
|
276
|
+
blocks: readonly BlockOffsetsForVisibility[];
|
|
277
|
+
pageCount: number;
|
|
278
|
+
visiblePageIndexRange: VisiblePageIndexRange;
|
|
279
|
+
selectionBlockIndex: number | null;
|
|
280
|
+
totalBlockCount: number;
|
|
281
|
+
getPageOffsets: (pageIndex: number) => PageOffsetsForVisibility | null;
|
|
282
|
+
}): readonly BlockRange[] | null {
|
|
283
|
+
const {
|
|
284
|
+
blocks,
|
|
285
|
+
pageCount,
|
|
286
|
+
visiblePageIndexRange,
|
|
287
|
+
selectionBlockIndex,
|
|
288
|
+
totalBlockCount,
|
|
289
|
+
getPageOffsets,
|
|
290
|
+
} = input;
|
|
291
|
+
if (totalBlockCount <= 0) return [];
|
|
292
|
+
const normalized = clampPageRange(visiblePageIndexRange, pageCount);
|
|
293
|
+
if (!normalized) return null;
|
|
294
|
+
|
|
295
|
+
let startOffset = Infinity;
|
|
296
|
+
let endOffset = -Infinity;
|
|
297
|
+
for (let pageIndex = normalized.start; pageIndex < normalized.end; pageIndex += 1) {
|
|
298
|
+
const page = getPageOffsets(pageIndex);
|
|
299
|
+
if (!page) continue;
|
|
300
|
+
if (page.endOffset <= page.startOffset) continue;
|
|
301
|
+
startOffset = Math.min(startOffset, page.startOffset);
|
|
302
|
+
endOffset = Math.max(endOffset, page.endOffset);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (startOffset === Infinity || endOffset <= startOffset) return null;
|
|
306
|
+
const viewportRange = resolveBlockRangeFromOffsetSpan({
|
|
307
|
+
blocks,
|
|
308
|
+
startOffset,
|
|
309
|
+
endOffset,
|
|
310
|
+
totalBlockCount,
|
|
311
|
+
});
|
|
312
|
+
if (!viewportRange) return null;
|
|
313
|
+
|
|
314
|
+
return withSelectionGuard({
|
|
315
|
+
viewportRange,
|
|
316
|
+
selectionBlockIndex,
|
|
317
|
+
resolveSelectionRange: (selectionBlockIndexForGuard) =>
|
|
318
|
+
resolveSelectionRangeFromPageOffsets({
|
|
319
|
+
blocks,
|
|
320
|
+
pageCount,
|
|
321
|
+
getPageOffsets,
|
|
322
|
+
selectionBlockIndex: selectionBlockIndexForGuard,
|
|
323
|
+
totalBlockCount,
|
|
324
|
+
}),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function resolveVisiblePageIndexRangeFromViewport(input: {
|
|
329
|
+
pageCount: number;
|
|
330
|
+
viewportTopPx: number;
|
|
331
|
+
viewportHeightPx: number;
|
|
332
|
+
overscanPages: number;
|
|
333
|
+
getPageFrame: (pageIndex: number) => PageFrameForVisibility | null;
|
|
334
|
+
}): VisiblePageIndexRange | null {
|
|
335
|
+
const { pageCount, viewportTopPx, viewportHeightPx, overscanPages, getPageFrame } = input;
|
|
336
|
+
if (pageCount <= 0 || viewportHeightPx <= 0) return null;
|
|
337
|
+
|
|
338
|
+
const viewportBottomPx = viewportTopPx + viewportHeightPx;
|
|
339
|
+
let firstVisible = Infinity;
|
|
340
|
+
let lastVisible = -Infinity;
|
|
341
|
+
let nearestPageAfterViewport = -1;
|
|
342
|
+
let nearestPageAfterDistance = Infinity;
|
|
343
|
+
let lastPageBeforeViewport = -1;
|
|
344
|
+
let lastPageBeforeDistance = Infinity;
|
|
345
|
+
|
|
346
|
+
for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
|
|
347
|
+
const frame = getPageFrame(pageIndex);
|
|
348
|
+
if (!frame) continue;
|
|
349
|
+
const pageTopPx = frame.topPx;
|
|
350
|
+
const pageBottomPx = frame.topPx + frame.heightPx;
|
|
351
|
+
if (pageBottomPx >= viewportTopPx && pageTopPx <= viewportBottomPx) {
|
|
352
|
+
firstVisible = Math.min(firstVisible, pageIndex);
|
|
353
|
+
lastVisible = Math.max(lastVisible, pageIndex);
|
|
354
|
+
}
|
|
355
|
+
if (pageBottomPx < viewportTopPx) {
|
|
356
|
+
lastPageBeforeViewport = pageIndex;
|
|
357
|
+
lastPageBeforeDistance = viewportTopPx - pageBottomPx;
|
|
358
|
+
} else if (nearestPageAfterViewport < 0 && pageTopPx > viewportBottomPx) {
|
|
359
|
+
nearestPageAfterViewport = pageIndex;
|
|
360
|
+
nearestPageAfterDistance = pageTopPx - viewportBottomPx;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (firstVisible === Infinity) {
|
|
365
|
+
const fallbackPage =
|
|
366
|
+
nearestPageAfterViewport >= 0 && lastPageBeforeViewport >= 0
|
|
367
|
+
? nearestPageAfterDistance < lastPageBeforeDistance
|
|
368
|
+
? nearestPageAfterViewport
|
|
369
|
+
: lastPageBeforeViewport
|
|
370
|
+
: nearestPageAfterViewport >= 0
|
|
371
|
+
? nearestPageAfterViewport
|
|
372
|
+
: lastPageBeforeViewport >= 0
|
|
373
|
+
? lastPageBeforeViewport
|
|
374
|
+
: -1;
|
|
375
|
+
if (fallbackPage < 0) return null;
|
|
376
|
+
firstVisible = fallbackPage;
|
|
377
|
+
lastVisible = fallbackPage;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return clampPageRange(
|
|
381
|
+
{
|
|
382
|
+
start: firstVisible - overscanPages,
|
|
383
|
+
end: lastVisible + overscanPages + 1,
|
|
384
|
+
},
|
|
385
|
+
pageCount,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
70
389
|
/**
|
|
71
390
|
* Multi-interval hook. Returns the list of non-overlapping block-index
|
|
72
391
|
* intervals that should be real in the surface projection. Typical output:
|
|
@@ -121,8 +440,7 @@ export function useVisibleBlockRanges(input: VisibleBlockRangeInput): readonly B
|
|
|
121
440
|
if (totalBlockCount <= 0) return [];
|
|
122
441
|
if (visiblePages.size === 0 && selectionBlockIndex === null) {
|
|
123
442
|
// No visibility signal yet — return initial-load window (first 2 × overscanPages worth).
|
|
124
|
-
|
|
125
|
-
return initialEnd > 0 ? [{ start: 0, end: initialEnd }] : [];
|
|
443
|
+
return initialLoadRange(totalBlockCount, overscanPages);
|
|
126
444
|
}
|
|
127
445
|
|
|
128
446
|
// Expand visiblePages by ±overscanPages.
|
|
@@ -158,48 +476,20 @@ export function useVisibleBlockRanges(input: VisibleBlockRangeInput): readonly B
|
|
|
158
476
|
// to cover the gap. On long documents with the caret far from the
|
|
159
477
|
// viewport this avoids realizing the entire gap as real blocks (the
|
|
160
478
|
// 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
479
|
// Sort ascending by `start` so the facet layer sees a canonical order
|
|
195
480
|
// and can dedupe with a simple serialization. Runtime's
|
|
196
481
|
// `normalizeViewportRanges` merges touching/overlapping intervals, so
|
|
197
482
|
// we don't need to handle that here.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
483
|
+
return withSelectionGuard({
|
|
484
|
+
viewportRange,
|
|
485
|
+
selectionBlockIndex,
|
|
486
|
+
resolveSelectionRange: (selectionBlockIndexForGuard) =>
|
|
487
|
+
resolveSelectionRange({
|
|
488
|
+
pageMarkers,
|
|
489
|
+
selectionBlockIndex: selectionBlockIndexForGuard,
|
|
490
|
+
totalBlockCount,
|
|
491
|
+
}),
|
|
492
|
+
});
|
|
203
493
|
}, [visiblePages, selectionBlockIndex, pageMarkers, overscanPages, totalBlockCount]);
|
|
204
494
|
}
|
|
205
495
|
|
|
@@ -2,6 +2,7 @@ import { useMemo } from "react";
|
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
4
|
ActiveListContext,
|
|
5
|
+
EditorStoryTarget,
|
|
5
6
|
EditorViewStateSnapshot,
|
|
6
7
|
InteractionGuardSnapshot,
|
|
7
8
|
WordReviewEditorChromePreset,
|
|
@@ -24,6 +25,7 @@ export interface UseChromePolicyOptions {
|
|
|
24
25
|
role: EditorViewStateSnapshot["editorRole"];
|
|
25
26
|
hasSidebarPanelAccess: boolean;
|
|
26
27
|
effectiveSelectionMode: ToolbarInteractionPolicy["mode"];
|
|
28
|
+
activeStoryKind?: EditorStoryTarget["kind"];
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export interface ChromePolicy {
|
|
@@ -52,8 +54,22 @@ export function useChromePolicy(options: UseChromePolicyOptions): ChromePolicy {
|
|
|
52
54
|
role,
|
|
53
55
|
hasSidebarPanelAccess,
|
|
54
56
|
effectiveSelectionMode,
|
|
57
|
+
activeStoryKind,
|
|
55
58
|
} = options;
|
|
56
59
|
|
|
60
|
+
const canRunCommentCommand = resolveTopBarCommentAvailability({
|
|
61
|
+
caps,
|
|
62
|
+
effectiveSelectionMode,
|
|
63
|
+
activeStoryKind,
|
|
64
|
+
});
|
|
65
|
+
const chromeCaps = useMemo(
|
|
66
|
+
() =>
|
|
67
|
+
caps && caps.canAddComment !== canRunCommentCommand
|
|
68
|
+
? { ...caps, canAddComment: canRunCommentCommand }
|
|
69
|
+
: caps,
|
|
70
|
+
[canRunCommentCommand, caps],
|
|
71
|
+
);
|
|
72
|
+
|
|
57
73
|
const responsiveChrome = useMemo(
|
|
58
74
|
() =>
|
|
59
75
|
resolveResponsiveChromeState({
|
|
@@ -69,7 +85,7 @@ export function useChromePolicy(options: UseChromePolicyOptions): ChromePolicy {
|
|
|
69
85
|
resolveScopedChromePolicy({
|
|
70
86
|
preset: chromePreset,
|
|
71
87
|
compactMode: responsiveChrome.isNarrow,
|
|
72
|
-
capabilities:
|
|
88
|
+
capabilities: chromeCaps,
|
|
73
89
|
interactionGuardSnapshot,
|
|
74
90
|
workflowScopeSnapshot,
|
|
75
91
|
activeListContext,
|
|
@@ -77,7 +93,7 @@ export function useChromePolicy(options: UseChromePolicyOptions): ChromePolicy {
|
|
|
77
93
|
hasSidebarPanelAccess,
|
|
78
94
|
}),
|
|
79
95
|
[
|
|
80
|
-
|
|
96
|
+
chromeCaps,
|
|
81
97
|
chromePreset,
|
|
82
98
|
hasSidebarPanelAccess,
|
|
83
99
|
activeListContext,
|
|
@@ -93,12 +109,22 @@ export function useChromePolicy(options: UseChromePolicyOptions): ChromePolicy {
|
|
|
93
109
|
mode: effectiveSelectionMode,
|
|
94
110
|
canFormatText: caps.canEdit && effectiveSelectionMode === "edit",
|
|
95
111
|
canInsertStructural: caps.canEdit && effectiveSelectionMode === "edit",
|
|
96
|
-
canAddComment:
|
|
97
|
-
caps.canAddComment &&
|
|
98
|
-
effectiveSelectionMode !== "view" &&
|
|
99
|
-
effectiveSelectionMode !== "blocked",
|
|
112
|
+
canAddComment: canRunCommentCommand,
|
|
100
113
|
}
|
|
101
114
|
: undefined;
|
|
102
115
|
|
|
103
116
|
return { responsiveChrome, scopedChromePolicy, toolbarInteractionPolicy };
|
|
104
117
|
}
|
|
118
|
+
|
|
119
|
+
export function resolveTopBarCommentAvailability(input: {
|
|
120
|
+
caps: SessionCapabilities | undefined;
|
|
121
|
+
effectiveSelectionMode: ToolbarInteractionPolicy["mode"];
|
|
122
|
+
activeStoryKind?: EditorStoryTarget["kind"];
|
|
123
|
+
}): boolean {
|
|
124
|
+
const { caps, effectiveSelectionMode, activeStoryKind } = input;
|
|
125
|
+
if (!caps) return false;
|
|
126
|
+
if (caps.phase !== "ready" || caps.hasFatalError) return false;
|
|
127
|
+
if (caps.documentMode === "viewing") return false;
|
|
128
|
+
if (activeStoryKind !== undefined && activeStoryKind !== "main") return false;
|
|
129
|
+
return effectiveSelectionMode !== "view" && effectiveSelectionMode !== "blocked";
|
|
130
|
+
}
|