@plannotator/ui 0.54.0 → 0.55.0
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/HANDOFF.md +59 -5
- package/README.md +50 -2
- package/components/BlockRenderer.tsx +4 -1
- package/components/CodeSnippetPreview.tsx +174 -0
- package/components/DeferredMarkdownContentsRail.tsx +38 -0
- package/components/DiffFileTree.tsx +7 -6
- package/components/DocumentContents.tsx +74 -0
- package/components/ImageLightbox.tsx +121 -63
- package/components/InlineMarkdown.tsx +15 -150
- package/components/MarkdownContentsRail.tsx +777 -0
- package/components/TableOfContents.tsx +20 -6
- package/components/Viewer.tsx +8 -2
- package/components/blocks/QuestionBlock.tsx +14 -4
- package/components/html-viewer/HtmlViewer.tsx +36 -1
- package/components/imageZoom.ts +11 -0
- package/contents-rail.css +337 -0
- package/hooks/useHeadingScrollSpy.ts +77 -0
- package/hooks/useHoverPreview.ts +94 -0
- package/hooks/usePrefersReducedMotion.ts +23 -0
- package/hooks/useSwipeStep.ts +597 -0
- package/package.json +5 -3
- package/styles.css +1 -1
- package/utils/annotationHelpers.ts +32 -4
- package/utils/blockParse.ts +45 -0
- package/utils/headingScrollSpy.ts +92 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
// Moved from the Workspaces app's MarkdownContentsRail (apps/web/src/plannotator/
|
|
2
|
+
// MarkdownContentsRail.tsx), the document contents rail approved 2026-10-03
|
|
3
|
+
// ("it should just be on the right side": the ticks in the document's right
|
|
4
|
+
// gutter, the Contents panel on click, no hover reveal, no Left/Right setting).
|
|
5
|
+
// The owner approved the move into @plannotator/ui on 2026-10-30 so the
|
|
6
|
+
// Plannotator Inbox can use it. Port:
|
|
7
|
+
// - Workspaces-only imports moved into the package: the shared parse memo
|
|
8
|
+
// (utils/blockParse) and usePrefersReducedMotion (hooks/); `cn` had nothing
|
|
9
|
+
// left to join once the `floating-surface` chrome and the EYEBROW title
|
|
10
|
+
// became the rail's own
|
|
11
|
+
// `pn-contents-rail-*` rules in theme.css, on the same tokens
|
|
12
|
+
// (--popover, --shadow-menu, --geometry-floating-radius, ...) with fallbacks.
|
|
13
|
+
// The `ws-markdown-contents-*` classes and `--ws-contents-*` properties are
|
|
14
|
+
// `pn-contents-rail-*` and `--pn-contents-*`.
|
|
15
|
+
// - The rail's scroll math (cached heading tops, the binary search, the last
|
|
16
|
+
// heading at the document's end, re-binding on DOM change, a pick marked at
|
|
17
|
+
// once) moved as it was into utils/headingScrollSpy, so `DocumentContents`
|
|
18
|
+
// uses it too; the activation line is 80 px (it was 52), plan review's
|
|
19
|
+
// scroll offset, so a pick lands where plan review's does. DOM changes are
|
|
20
|
+
// watched on the whole scroll element, so a re-rendered reading column is
|
|
21
|
+
// re-bound too.
|
|
22
|
+
// - The headings come from `tocHeadings(blocks, 4)`.
|
|
23
|
+
// - Host seams, all optional (Workspaces passes none and is unchanged):
|
|
24
|
+
// `blocks` (a host that parsed already), `contentSelector` (the reading
|
|
25
|
+
// column), `hidden` + `onAvailableChange` (a host's own hide choice and
|
|
26
|
+
// fallback), `panelAction` (one control in the open panel's title row).
|
|
27
|
+
// - Escape also closes a panel opened by pointer while focus stays outside
|
|
28
|
+
// the rail (a tick press never takes focus), unless focus is in an editable
|
|
29
|
+
// control, a dialog, a menu or a listbox. It is preventDefault-ed, not stopped: a host's own
|
|
30
|
+
// Escape sees it was used.
|
|
31
|
+
// - The rules are the package's own stylesheet, contents-rail.css (import it
|
|
32
|
+
// outside any CSS layer), not part of theme.css.
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
useCallback,
|
|
36
|
+
useEffect,
|
|
37
|
+
useMemo,
|
|
38
|
+
useRef,
|
|
39
|
+
useState,
|
|
40
|
+
type CSSProperties,
|
|
41
|
+
type KeyboardEvent,
|
|
42
|
+
type PointerEvent as ReactPointerEvent,
|
|
43
|
+
type ReactNode,
|
|
44
|
+
} from 'react';
|
|
45
|
+
|
|
46
|
+
import type { Block } from '../types';
|
|
47
|
+
import { extractFrontmatter } from '../utils/parser';
|
|
48
|
+
import { parseBlocksShared } from '../utils/blockParse';
|
|
49
|
+
import {
|
|
50
|
+
CONTENTS_RAIL_MAX_LEVEL,
|
|
51
|
+
CONTENTS_RAIL_MIN_HEADINGS,
|
|
52
|
+
CONTENTS_RAIL_MIN_PANE_WIDTH,
|
|
53
|
+
tocHeadings,
|
|
54
|
+
} from '../utils/annotationHelpers';
|
|
55
|
+
import {
|
|
56
|
+
CONTENTS_ACTIVATION_OFFSET,
|
|
57
|
+
activeHeadingBinding,
|
|
58
|
+
bindHeadings,
|
|
59
|
+
topInsideViewport,
|
|
60
|
+
type HeadingBinding,
|
|
61
|
+
} from '../utils/headingScrollSpy';
|
|
62
|
+
import { usePrefersReducedMotion } from '../hooks/usePrefersReducedMotion';
|
|
63
|
+
|
|
64
|
+
const MIN_HEADING_COUNT = CONTENTS_RAIL_MIN_HEADINGS;
|
|
65
|
+
const COLLAPSED_MARKER_LIMIT = 10;
|
|
66
|
+
const PANEL_WIDTH = 256;
|
|
67
|
+
const DOCK_WIDTH = 36;
|
|
68
|
+
const PANEL_GAP = 6;
|
|
69
|
+
const CONTENT_GAP = 24;
|
|
70
|
+
const VIEWPORT_EDGE_INSET = 8;
|
|
71
|
+
const MIN_RAIL_VIEWPORT_WIDTH = CONTENTS_RAIL_MIN_PANE_WIDTH;
|
|
72
|
+
const SMOOTH_SCROLL_SETTLE = 350;
|
|
73
|
+
const SCROLL_CORRECTION_THRESHOLD = 24;
|
|
74
|
+
/** The reading column, by default: the element Workspaces marks around its document. */
|
|
75
|
+
const DEFAULT_CONTENT_SELECTOR = '[data-document-read-content]';
|
|
76
|
+
|
|
77
|
+
export interface MarkdownContentsItem {
|
|
78
|
+
readonly key: string;
|
|
79
|
+
readonly blockId: string;
|
|
80
|
+
readonly label: string;
|
|
81
|
+
readonly level: number;
|
|
82
|
+
readonly breadcrumb: string;
|
|
83
|
+
readonly excerpt: string | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface RailGeometry {
|
|
87
|
+
readonly left: number;
|
|
88
|
+
readonly top: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface CondensedContentsMarker {
|
|
92
|
+
readonly itemIndex: number;
|
|
93
|
+
readonly rangeStart: number;
|
|
94
|
+
readonly rangeEnd: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Place only the persistent marker dock in the document's existing right
|
|
98
|
+
* padding, 24px right of the reading column (owner ruling 2026-10-03: the
|
|
99
|
+
* rail returns to the right gutter it held from PR 76 until PR 241). The
|
|
100
|
+
* Contents surface opens to the LEFT as an overlay, away from a docked
|
|
101
|
+
* comments column, so this accessory never changes document width. `left`
|
|
102
|
+
* is the dock's left edge. */
|
|
103
|
+
export function resolveContentsRailGeometry(
|
|
104
|
+
viewportRight: number,
|
|
105
|
+
contentRight: number,
|
|
106
|
+
viewportTop: number,
|
|
107
|
+
viewportHeight: number,
|
|
108
|
+
): RailGeometry {
|
|
109
|
+
return {
|
|
110
|
+
left: Math.min(
|
|
111
|
+
Math.floor(viewportRight - VIEWPORT_EDGE_INSET - DOCK_WIDTH),
|
|
112
|
+
Math.floor(contentRight + CONTENT_GAP),
|
|
113
|
+
),
|
|
114
|
+
top: Math.round(viewportTop + viewportHeight / 2),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Keep the rail out of compact document panes and require a complete marker
|
|
119
|
+
* lane in the document's existing right padding. The pane's right edge is the
|
|
120
|
+
* docked comments (or people) column's edge when one is open, so the rail
|
|
121
|
+
* yields to the column instead of overlapping it or its resize edge. The
|
|
122
|
+
* panel itself may still overlay the prose once the marker lane qualifies. */
|
|
123
|
+
export function canShowContentsRail(
|
|
124
|
+
viewportWidth: number,
|
|
125
|
+
viewportRight: number,
|
|
126
|
+
contentRight: number,
|
|
127
|
+
): boolean {
|
|
128
|
+
return (
|
|
129
|
+
viewportWidth >= MIN_RAIL_VIEWPORT_WIDTH &&
|
|
130
|
+
viewportRight - contentRight >= VIEWPORT_EDGE_INSET + DOCK_WIDTH + CONTENT_GAP
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function plainText(value: string): string {
|
|
135
|
+
return value
|
|
136
|
+
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
|
|
137
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
138
|
+
.replace(
|
|
139
|
+
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
|
|
140
|
+
(_, target: string, label?: string) => label ?? target,
|
|
141
|
+
)
|
|
142
|
+
.replace(/<[^>]+>/g, ' ')
|
|
143
|
+
.replace(/[*_`~>#]/g, '')
|
|
144
|
+
.replace(/\s+/g, ' ')
|
|
145
|
+
.trim();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function excerptAfterHeading(blocks: readonly Block[], headingIndex: number): string | null {
|
|
149
|
+
for (let index = headingIndex + 1; index < blocks.length; index++) {
|
|
150
|
+
const block = blocks[index];
|
|
151
|
+
if (block.type === 'heading') return null;
|
|
152
|
+
if (block.type === 'code' || block.type === 'html' || block.type === 'hr') continue;
|
|
153
|
+
const text = plainText(block.content);
|
|
154
|
+
if (text.length === 0) continue;
|
|
155
|
+
return text.length > 180 ? `${text.slice(0, 177).trimEnd()}…` : text;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function buildContentsItems(blocks: readonly Block[]): readonly MarkdownContentsItem[] {
|
|
161
|
+
const listed = new Set(tocHeadings(blocks, CONTENTS_RAIL_MAX_LEVEL));
|
|
162
|
+
const parents: Array<{ level: number; label: string }> = [];
|
|
163
|
+
const items: MarkdownContentsItem[] = [];
|
|
164
|
+
|
|
165
|
+
for (let index = 0; index < blocks.length; index++) {
|
|
166
|
+
const block = blocks[index];
|
|
167
|
+
if (block.type !== 'heading' || !listed.has(block)) continue;
|
|
168
|
+
const level = Math.min(6, Math.max(1, block.level ?? 1));
|
|
169
|
+
const label = plainText(block.content) || 'Untitled section';
|
|
170
|
+
while (parents.length > 0 && parents.at(-1)!.level >= level) parents.pop();
|
|
171
|
+
const breadcrumb = [...parents.map((entry) => entry.label), label].join(' / ');
|
|
172
|
+
items.push({
|
|
173
|
+
key: block.id,
|
|
174
|
+
blockId: block.id,
|
|
175
|
+
label,
|
|
176
|
+
level,
|
|
177
|
+
breadcrumb,
|
|
178
|
+
excerpt: excerptAfterHeading(blocks, index),
|
|
179
|
+
});
|
|
180
|
+
parents.push({ level, label });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return items;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function markdownBlocks(markdown: string): Block[] {
|
|
187
|
+
return parseBlocksShared(extractFrontmatter(markdown).content);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build the semantic index from the same cached parse the Viewer consumes.
|
|
192
|
+
* DOM binding happens later, after the package has assigned its duplicate-safe
|
|
193
|
+
* heading anchors. Scroll never reparses or searches the document.
|
|
194
|
+
*/
|
|
195
|
+
export function buildMarkdownContentsModel(markdown: string): readonly MarkdownContentsItem[] {
|
|
196
|
+
return buildContentsItems(markdownBlocks(markdown));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Divide a long document into ten ordered spans, then represent each span with
|
|
201
|
+
* its shallowest heading nearest the span midpoint. This is one O(n) pass when
|
|
202
|
+
* the Markdown model changes; scroll updates only map the active index to a
|
|
203
|
+
* slot with arithmetic.
|
|
204
|
+
*/
|
|
205
|
+
export function condenseMarkdownContentsMarkers(
|
|
206
|
+
items: readonly MarkdownContentsItem[],
|
|
207
|
+
): readonly CondensedContentsMarker[] {
|
|
208
|
+
if (items.length <= COLLAPSED_MARKER_LIMIT) {
|
|
209
|
+
return items.map((_, itemIndex) => ({
|
|
210
|
+
itemIndex,
|
|
211
|
+
rangeStart: itemIndex,
|
|
212
|
+
rangeEnd: itemIndex,
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return Array.from({ length: COLLAPSED_MARKER_LIMIT }, (_, slotIndex) => {
|
|
217
|
+
const rangeStart = Math.ceil((slotIndex * items.length) / COLLAPSED_MARKER_LIMIT);
|
|
218
|
+
const rangeEnd = Math.ceil(((slotIndex + 1) * items.length) / COLLAPSED_MARKER_LIMIT) - 1;
|
|
219
|
+
const midpoint = (rangeStart + rangeEnd) / 2;
|
|
220
|
+
let itemIndex = rangeStart;
|
|
221
|
+
|
|
222
|
+
for (let candidateIndex = rangeStart + 1; candidateIndex <= rangeEnd; candidateIndex++) {
|
|
223
|
+
const candidate = items[candidateIndex];
|
|
224
|
+
const current = items[itemIndex];
|
|
225
|
+
if (
|
|
226
|
+
candidate.level < current.level ||
|
|
227
|
+
(candidate.level === current.level &&
|
|
228
|
+
Math.abs(candidateIndex - midpoint) < Math.abs(itemIndex - midpoint))
|
|
229
|
+
) {
|
|
230
|
+
itemIndex = candidateIndex;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { itemIndex, rangeStart, rangeEnd };
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function condensedSlotForItem(itemIndex: number, itemCount: number, slotCount: number): number {
|
|
239
|
+
if (itemCount <= 0 || slotCount <= 1) return 0;
|
|
240
|
+
const boundedIndex = Math.min(itemCount - 1, Math.max(0, itemIndex));
|
|
241
|
+
return Math.min(slotCount - 1, Math.floor((boundedIndex * slotCount) / itemCount));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Focus in an editable control, a dialog, a menu or a listbox: its Escape is its own. */
|
|
245
|
+
function ownsEscape(element: HTMLElement): boolean {
|
|
246
|
+
return (
|
|
247
|
+
element.isContentEditable ||
|
|
248
|
+
element.closest('input, textarea, select, [role="dialog"], dialog, [role="menu"], [role="listbox"]') !== null
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function setViewportScroll(viewport: HTMLElement, top: number, behavior: ScrollBehavior): void {
|
|
253
|
+
viewport.scrollTo({ top: Math.max(0, top), behavior });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface MarkdownContentsRailProps {
|
|
257
|
+
/** The document's Markdown (frontmatter is skipped). Ignored when `blocks` is given. */
|
|
258
|
+
readonly markdown?: string;
|
|
259
|
+
/** The document's parsed blocks, for a host that already has them (the Viewer's). */
|
|
260
|
+
readonly blocks?: readonly Block[];
|
|
261
|
+
/** The element the document scrolls in; the rail sits in its right gutter. */
|
|
262
|
+
readonly scrollViewport: HTMLElement | null;
|
|
263
|
+
/** Unused by the rail; kept so Workspaces' call site compiles unchanged. */
|
|
264
|
+
readonly documentPath?: string;
|
|
265
|
+
/**
|
|
266
|
+
* The reading column inside `scrollViewport`: the dock goes 24 px right of
|
|
267
|
+
* its right edge. Default `[data-document-read-content]` (Workspaces). The
|
|
268
|
+
* Plannotator Inbox passes the Viewer's `[data-doc-text-root]`.
|
|
269
|
+
*/
|
|
270
|
+
readonly contentSelector?: string;
|
|
271
|
+
/**
|
|
272
|
+
* The host's own hide choice: the rail keeps measuring (so
|
|
273
|
+
* `onAvailableChange` still reports) but draws nothing. Default false.
|
|
274
|
+
*/
|
|
275
|
+
readonly hidden?: boolean;
|
|
276
|
+
/**
|
|
277
|
+
* Whether the rail can show: enough headings, a pane at least 480 px wide
|
|
278
|
+
* and a 68 px gutter. Called when it changes, so a host can offer its own
|
|
279
|
+
* way to Contents when the rail yields.
|
|
280
|
+
*/
|
|
281
|
+
readonly onAvailableChange?: (available: boolean) => void;
|
|
282
|
+
/**
|
|
283
|
+
* One host control drawn at the right of the open panel's "Contents" title
|
|
284
|
+
* (the Inbox's Hide). Shown only while the panel is open; Tab reaches it
|
|
285
|
+
* after the headings. Default: none.
|
|
286
|
+
*/
|
|
287
|
+
readonly panelAction?: ReactNode;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function MarkdownContentsRail({
|
|
291
|
+
markdown = '',
|
|
292
|
+
blocks: hostBlocks,
|
|
293
|
+
scrollViewport,
|
|
294
|
+
contentSelector = DEFAULT_CONTENT_SELECTOR,
|
|
295
|
+
hidden = false,
|
|
296
|
+
onAvailableChange,
|
|
297
|
+
panelAction,
|
|
298
|
+
}: MarkdownContentsRailProps) {
|
|
299
|
+
const items = useMemo(
|
|
300
|
+
() => buildContentsItems(hostBlocks ?? markdownBlocks(markdown)),
|
|
301
|
+
[hostBlocks, markdown],
|
|
302
|
+
);
|
|
303
|
+
const reducedMotion = usePrefersReducedMotion();
|
|
304
|
+
// null until the first measure: nothing is reported before the rail knows.
|
|
305
|
+
const [available, setAvailable] = useState<boolean | null>(null);
|
|
306
|
+
const [interactionIndex, setInteractionIndex] = useState<number | null>(null);
|
|
307
|
+
const [titlesVisible, setTitlesVisible] = useState(false);
|
|
308
|
+
const [railGeometry, setRailGeometry] = useState<RailGeometry>({
|
|
309
|
+
left: VIEWPORT_EDGE_INSET,
|
|
310
|
+
top: 0,
|
|
311
|
+
});
|
|
312
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
313
|
+
const visible = available === true && !hidden;
|
|
314
|
+
const condensedMarkers = useMemo(() => condenseMarkdownContentsMarkers(items), [items]);
|
|
315
|
+
const safeActiveIndex = Math.min(Math.max(0, activeIndex), Math.max(0, items.length - 1));
|
|
316
|
+
const collapsedActiveSlot = condensedSlotForItem(
|
|
317
|
+
safeActiveIndex,
|
|
318
|
+
items.length,
|
|
319
|
+
condensedMarkers.length,
|
|
320
|
+
);
|
|
321
|
+
const renderedItemIndexes = useMemo(
|
|
322
|
+
() =>
|
|
323
|
+
titlesVisible
|
|
324
|
+
? items.map((_, itemIndex) => itemIndex)
|
|
325
|
+
: condensedMarkers.map((marker, slotIndex) =>
|
|
326
|
+
slotIndex === collapsedActiveSlot ? safeActiveIndex : marker.itemIndex,
|
|
327
|
+
),
|
|
328
|
+
[collapsedActiveSlot, condensedMarkers, items, safeActiveIndex, titlesVisible],
|
|
329
|
+
);
|
|
330
|
+
const activeIndexRef = useRef(0);
|
|
331
|
+
const bindingsRef = useRef<readonly HeadingBinding[]>([]);
|
|
332
|
+
const markerRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
333
|
+
const listRef = useRef<HTMLDivElement | null>(null);
|
|
334
|
+
const railRef = useRef<HTMLElement | null>(null);
|
|
335
|
+
const actionRef = useRef<HTMLDivElement | null>(null);
|
|
336
|
+
const activeFrameRef = useRef<number | null>(null);
|
|
337
|
+
const measureFrameRef = useRef<number | null>(null);
|
|
338
|
+
const correctionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
339
|
+
const scrubbingRef = useRef(false);
|
|
340
|
+
const scrubbedIndexRef = useRef<number | null>(null);
|
|
341
|
+
const onAvailableChangeRef = useRef(onAvailableChange);
|
|
342
|
+
onAvailableChangeRef.current = onAvailableChange;
|
|
343
|
+
const blockIds = useMemo(() => items.map((item) => item.blockId), [items]);
|
|
344
|
+
|
|
345
|
+
const commitActiveIndex = useCallback((next: number) => {
|
|
346
|
+
if (activeIndexRef.current === next) return;
|
|
347
|
+
activeIndexRef.current = next;
|
|
348
|
+
setActiveIndex(next);
|
|
349
|
+
}, []);
|
|
350
|
+
|
|
351
|
+
const refreshActiveSection = useCallback(() => {
|
|
352
|
+
if (scrollViewport === null) return;
|
|
353
|
+
// No binding (nothing drawn yet): the index stays.
|
|
354
|
+
const binding = activeHeadingBinding(bindingsRef.current, scrollViewport);
|
|
355
|
+
if (binding !== null) commitActiveIndex(binding.itemIndex);
|
|
356
|
+
}, [commitActiveIndex, scrollViewport]);
|
|
357
|
+
|
|
358
|
+
const scheduleActiveRefresh = useCallback(() => {
|
|
359
|
+
if (activeFrameRef.current !== null) return;
|
|
360
|
+
activeFrameRef.current = requestAnimationFrame(() => {
|
|
361
|
+
activeFrameRef.current = null;
|
|
362
|
+
refreshActiveSection();
|
|
363
|
+
});
|
|
364
|
+
}, [refreshActiveSection]);
|
|
365
|
+
|
|
366
|
+
const measure = useCallback(() => {
|
|
367
|
+
if (scrollViewport === null || items.length < MIN_HEADING_COUNT) {
|
|
368
|
+
bindingsRef.current = [];
|
|
369
|
+
setAvailable(false);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const bindings = bindHeadings(scrollViewport, blockIds);
|
|
374
|
+
bindingsRef.current = bindings;
|
|
375
|
+
|
|
376
|
+
const viewportRect = scrollViewport.getBoundingClientRect();
|
|
377
|
+
// The scroll surface's own scrollbar sits in the right gutter: measure
|
|
378
|
+
// the lane up to the scrollbar, never under it.
|
|
379
|
+
const scrollbarWidth =
|
|
380
|
+
scrollViewport.clientWidth > 0
|
|
381
|
+
? Math.max(
|
|
382
|
+
0,
|
|
383
|
+
scrollViewport.offsetWidth - scrollViewport.clientWidth - scrollViewport.clientLeft,
|
|
384
|
+
)
|
|
385
|
+
: 0;
|
|
386
|
+
const viewportRight = viewportRect.right - scrollbarWidth;
|
|
387
|
+
const content = scrollViewport.querySelector<HTMLElement>(contentSelector);
|
|
388
|
+
const contentRect = content?.getBoundingClientRect();
|
|
389
|
+
const hasRailSpace =
|
|
390
|
+
contentRect !== undefined &&
|
|
391
|
+
canShowContentsRail(viewportRect.width, viewportRight, contentRect.right);
|
|
392
|
+
const geometry = resolveContentsRailGeometry(
|
|
393
|
+
viewportRight,
|
|
394
|
+
contentRect?.right ?? viewportRight - VIEWPORT_EDGE_INSET - DOCK_WIDTH - CONTENT_GAP,
|
|
395
|
+
viewportRect.top,
|
|
396
|
+
viewportRect.height,
|
|
397
|
+
);
|
|
398
|
+
setRailGeometry((current) =>
|
|
399
|
+
current.left === geometry.left && current.top === geometry.top ? current : geometry,
|
|
400
|
+
);
|
|
401
|
+
setAvailable(bindings.length >= MIN_HEADING_COUNT && hasRailSpace);
|
|
402
|
+
refreshActiveSection();
|
|
403
|
+
}, [blockIds, contentSelector, items.length, refreshActiveSection, scrollViewport]);
|
|
404
|
+
|
|
405
|
+
const scheduleMeasure = useCallback(() => {
|
|
406
|
+
if (measureFrameRef.current !== null) cancelAnimationFrame(measureFrameRef.current);
|
|
407
|
+
measureFrameRef.current = requestAnimationFrame(() => {
|
|
408
|
+
measureFrameRef.current = null;
|
|
409
|
+
measure();
|
|
410
|
+
});
|
|
411
|
+
}, [measure]);
|
|
412
|
+
|
|
413
|
+
useEffect(() => {
|
|
414
|
+
activeIndexRef.current = 0;
|
|
415
|
+
setActiveIndex(0);
|
|
416
|
+
setInteractionIndex(null);
|
|
417
|
+
setTitlesVisible(false);
|
|
418
|
+
}, [items]);
|
|
419
|
+
|
|
420
|
+
useEffect(() => {
|
|
421
|
+
if (available !== null) onAvailableChangeRef.current?.(available);
|
|
422
|
+
}, [available]);
|
|
423
|
+
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
if (scrollViewport === null || items.length < MIN_HEADING_COUNT) {
|
|
426
|
+
setAvailable(false);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let secondFrame = 0;
|
|
431
|
+
const firstFrame = requestAnimationFrame(() => {
|
|
432
|
+
secondFrame = requestAnimationFrame(measure);
|
|
433
|
+
});
|
|
434
|
+
scrollViewport.addEventListener('scroll', scheduleActiveRefresh, { passive: true });
|
|
435
|
+
scrollViewport.addEventListener('load', scheduleMeasure, true);
|
|
436
|
+
window.addEventListener('resize', scheduleMeasure);
|
|
437
|
+
|
|
438
|
+
// The reading column is observed for size; a host that re-renders it (a
|
|
439
|
+
// new element) is followed through the mutation observer below.
|
|
440
|
+
let observedContent: HTMLElement | null = null;
|
|
441
|
+
const resizeObserver =
|
|
442
|
+
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(scheduleMeasure);
|
|
443
|
+
const followContent = () => {
|
|
444
|
+
const content = scrollViewport.querySelector<HTMLElement>(contentSelector);
|
|
445
|
+
if (content === observedContent) return;
|
|
446
|
+
if (observedContent !== null) resizeObserver?.unobserve(observedContent);
|
|
447
|
+
observedContent = content;
|
|
448
|
+
if (content !== null) resizeObserver?.observe(content);
|
|
449
|
+
};
|
|
450
|
+
resizeObserver?.observe(scrollViewport);
|
|
451
|
+
followContent();
|
|
452
|
+
|
|
453
|
+
// Any change to the document's elements re-binds the headings (an edit
|
|
454
|
+
// can replace them and keep their count), except the rail's own.
|
|
455
|
+
const mutationObserver =
|
|
456
|
+
typeof MutationObserver === 'undefined'
|
|
457
|
+
? null
|
|
458
|
+
: new MutationObserver((records) => {
|
|
459
|
+
const rail = railRef.current;
|
|
460
|
+
if (rail !== null && records.every((record) => rail.contains(record.target))) return;
|
|
461
|
+
followContent();
|
|
462
|
+
scheduleMeasure();
|
|
463
|
+
});
|
|
464
|
+
mutationObserver?.observe(scrollViewport, { childList: true, subtree: true });
|
|
465
|
+
|
|
466
|
+
const fonts = typeof document === 'undefined' ? undefined : document.fonts;
|
|
467
|
+
if (fonts !== undefined) void fonts.ready.then(scheduleMeasure);
|
|
468
|
+
|
|
469
|
+
return () => {
|
|
470
|
+
cancelAnimationFrame(firstFrame);
|
|
471
|
+
cancelAnimationFrame(secondFrame);
|
|
472
|
+
if (activeFrameRef.current !== null) cancelAnimationFrame(activeFrameRef.current);
|
|
473
|
+
if (measureFrameRef.current !== null) cancelAnimationFrame(measureFrameRef.current);
|
|
474
|
+
scrollViewport.removeEventListener('scroll', scheduleActiveRefresh);
|
|
475
|
+
scrollViewport.removeEventListener('load', scheduleMeasure, true);
|
|
476
|
+
window.removeEventListener('resize', scheduleMeasure);
|
|
477
|
+
resizeObserver?.disconnect();
|
|
478
|
+
mutationObserver?.disconnect();
|
|
479
|
+
};
|
|
480
|
+
}, [contentSelector, items.length, measure, scheduleActiveRefresh, scheduleMeasure, scrollViewport]);
|
|
481
|
+
|
|
482
|
+
useEffect(() => {
|
|
483
|
+
const list = listRef.current;
|
|
484
|
+
const marker = markerRefs.current[activeIndex];
|
|
485
|
+
if (!visible || !titlesVisible || list === null || marker === null || marker === undefined)
|
|
486
|
+
return;
|
|
487
|
+
const markerTop = marker.offsetTop;
|
|
488
|
+
const markerBottom = markerTop + marker.offsetHeight;
|
|
489
|
+
if (markerTop < list.scrollTop) {
|
|
490
|
+
list.scrollTop = markerTop;
|
|
491
|
+
} else if (markerBottom > list.scrollTop + list.clientHeight) {
|
|
492
|
+
list.scrollTop = markerBottom - list.clientHeight;
|
|
493
|
+
}
|
|
494
|
+
}, [activeIndex, titlesVisible, visible]);
|
|
495
|
+
|
|
496
|
+
useEffect(
|
|
497
|
+
() => () => {
|
|
498
|
+
if (correctionTimerRef.current !== null) clearTimeout(correctionTimerRef.current);
|
|
499
|
+
},
|
|
500
|
+
[],
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
const closeTitles = useCallback(() => {
|
|
504
|
+
setTitlesVisible(false);
|
|
505
|
+
setInteractionIndex(null);
|
|
506
|
+
}, []);
|
|
507
|
+
|
|
508
|
+
useEffect(() => {
|
|
509
|
+
if (!titlesVisible) return;
|
|
510
|
+
const closeOnOutsidePointerDown = (event: PointerEvent) => {
|
|
511
|
+
const target = event.target;
|
|
512
|
+
if (target instanceof Node && railRef.current?.contains(target)) return;
|
|
513
|
+
closeTitles();
|
|
514
|
+
};
|
|
515
|
+
// Escape closes a panel opened by pointer, where focus stayed outside the
|
|
516
|
+
// rail (a tick press never takes focus). Not while focus is in an editable
|
|
517
|
+
// control, a dialog, a menu or a listbox: that Escape is theirs. preventDefault, never
|
|
518
|
+
// stopPropagation, so a host's own Escape (a full-screen view's) sees it was used.
|
|
519
|
+
const closeOnEscape = (event: globalThis.KeyboardEvent) => {
|
|
520
|
+
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
|
521
|
+
const focused = document.activeElement;
|
|
522
|
+
const inRail = focused instanceof Node && railRef.current?.contains(focused) === true;
|
|
523
|
+
if (!inRail && focused instanceof HTMLElement && ownsEscape(focused)) return;
|
|
524
|
+
event.preventDefault();
|
|
525
|
+
if (inRail && focused instanceof HTMLElement) focused.blur();
|
|
526
|
+
closeTitles();
|
|
527
|
+
};
|
|
528
|
+
document.addEventListener('pointerdown', closeOnOutsidePointerDown, true);
|
|
529
|
+
window.addEventListener('keydown', closeOnEscape, true);
|
|
530
|
+
return () => {
|
|
531
|
+
document.removeEventListener('pointerdown', closeOnOutsidePointerDown, true);
|
|
532
|
+
window.removeEventListener('keydown', closeOnEscape, true);
|
|
533
|
+
};
|
|
534
|
+
}, [closeTitles, titlesVisible]);
|
|
535
|
+
|
|
536
|
+
const highlightHeading = useCallback(
|
|
537
|
+
(node: HTMLElement) => {
|
|
538
|
+
if (reducedMotion || typeof node.animate !== 'function') return;
|
|
539
|
+
node.animate(
|
|
540
|
+
[
|
|
541
|
+
{ backgroundColor: 'color-mix(in oklch, var(--primary) 16%, transparent)' },
|
|
542
|
+
{ backgroundColor: 'transparent' },
|
|
543
|
+
],
|
|
544
|
+
{ duration: 1400, easing: 'ease-out' },
|
|
545
|
+
);
|
|
546
|
+
},
|
|
547
|
+
[reducedMotion],
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
const navigateTo = useCallback(
|
|
551
|
+
(itemIndex: number, scrubbing: boolean) => {
|
|
552
|
+
if (scrollViewport === null) return;
|
|
553
|
+
const binding = bindingsRef.current.find((entry) => entry.itemIndex === itemIndex);
|
|
554
|
+
if (binding === undefined) return;
|
|
555
|
+
const behavior: ScrollBehavior = reducedMotion || scrubbing ? 'auto' : 'smooth';
|
|
556
|
+
const targetTop = topInsideViewport(binding.node, scrollViewport) - CONTENTS_ACTIVATION_OFFSET;
|
|
557
|
+
setViewportScroll(scrollViewport, targetTop, behavior);
|
|
558
|
+
commitActiveIndex(itemIndex);
|
|
559
|
+
setInteractionIndex(itemIndex);
|
|
560
|
+
|
|
561
|
+
if (correctionTimerRef.current !== null) clearTimeout(correctionTimerRef.current);
|
|
562
|
+
correctionTimerRef.current = setTimeout(
|
|
563
|
+
() => {
|
|
564
|
+
const correctedTop =
|
|
565
|
+
topInsideViewport(binding.node, scrollViewport) - CONTENTS_ACTIVATION_OFFSET;
|
|
566
|
+
if (Math.abs(scrollViewport.scrollTop - correctedTop) > SCROLL_CORRECTION_THRESHOLD) {
|
|
567
|
+
setViewportScroll(scrollViewport, correctedTop, 'auto');
|
|
568
|
+
}
|
|
569
|
+
highlightHeading(binding.node);
|
|
570
|
+
},
|
|
571
|
+
behavior === 'smooth' ? SMOOTH_SCROLL_SETTLE : 0,
|
|
572
|
+
);
|
|
573
|
+
},
|
|
574
|
+
[commitActiveIndex, highlightHeading, reducedMotion, scrollViewport],
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
const openTitles = useCallback((index: number) => {
|
|
578
|
+
setInteractionIndex(index);
|
|
579
|
+
setTitlesVisible(true);
|
|
580
|
+
}, []);
|
|
581
|
+
|
|
582
|
+
const scrubAtPoint = useCallback(
|
|
583
|
+
(clientX: number, clientY: number) => {
|
|
584
|
+
const target = document.elementFromPoint(clientX, clientY);
|
|
585
|
+
const marker = target?.closest<HTMLElement>('[data-contents-index]');
|
|
586
|
+
if (marker === null || marker === undefined) return;
|
|
587
|
+
const index = Number(marker.dataset.contentsIndex);
|
|
588
|
+
if (!Number.isInteger(index) || scrubbedIndexRef.current === index) return;
|
|
589
|
+
scrubbedIndexRef.current = index;
|
|
590
|
+
navigateTo(index, true);
|
|
591
|
+
},
|
|
592
|
+
[navigateTo],
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
const onPointerDown = useCallback(
|
|
596
|
+
(event: ReactPointerEvent<HTMLButtonElement>, index: number) => {
|
|
597
|
+
if (!titlesVisible) {
|
|
598
|
+
event.preventDefault();
|
|
599
|
+
openTitles(index);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
event.preventDefault();
|
|
603
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
604
|
+
scrubbingRef.current = true;
|
|
605
|
+
scrubbedIndexRef.current = index;
|
|
606
|
+
navigateTo(index, true);
|
|
607
|
+
},
|
|
608
|
+
[navigateTo, openTitles, titlesVisible],
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
const onPointerMove = useCallback(
|
|
612
|
+
(event: ReactPointerEvent<HTMLButtonElement>) => {
|
|
613
|
+
if (!scrubbingRef.current) return;
|
|
614
|
+
scrubAtPoint(event.clientX, event.clientY);
|
|
615
|
+
},
|
|
616
|
+
[scrubAtPoint],
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
const endScrub = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
|
|
620
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
621
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
622
|
+
}
|
|
623
|
+
scrubbingRef.current = false;
|
|
624
|
+
scrubbedIndexRef.current = null;
|
|
625
|
+
}, []);
|
|
626
|
+
|
|
627
|
+
const onMarkerKeyDown = useCallback(
|
|
628
|
+
(event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
|
629
|
+
const markerPosition = renderedItemIndexes.indexOf(index);
|
|
630
|
+
let nextPosition: number | null = null;
|
|
631
|
+
if (event.key === 'Escape') {
|
|
632
|
+
event.preventDefault();
|
|
633
|
+
closeTitles();
|
|
634
|
+
event.currentTarget.blur();
|
|
635
|
+
return;
|
|
636
|
+
} else if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
|
637
|
+
nextPosition = Math.min(renderedItemIndexes.length - 1, markerPosition + 1);
|
|
638
|
+
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
|
639
|
+
nextPosition = Math.max(0, markerPosition - 1);
|
|
640
|
+
} else if (event.key === 'Home') {
|
|
641
|
+
nextPosition = 0;
|
|
642
|
+
} else if (event.key === 'End') {
|
|
643
|
+
nextPosition = renderedItemIndexes.length - 1;
|
|
644
|
+
} else if (event.key === 'Enter' || event.key === ' ') {
|
|
645
|
+
event.preventDefault();
|
|
646
|
+
if (!titlesVisible) {
|
|
647
|
+
openTitles(index);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
navigateTo(index, false);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (nextPosition === null || markerPosition < 0) return;
|
|
654
|
+
event.preventDefault();
|
|
655
|
+
const nextIndex = renderedItemIndexes[nextPosition];
|
|
656
|
+
if (nextIndex !== undefined) markerRefs.current[nextIndex]?.focus();
|
|
657
|
+
},
|
|
658
|
+
[closeTitles, navigateTo, openTitles, renderedItemIndexes, titlesVisible],
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
/** Focus left the panel (its list, and the host's action): the panel closes. */
|
|
662
|
+
const closeWhenFocusLeaves = useCallback(
|
|
663
|
+
(relatedTarget: EventTarget | null) => {
|
|
664
|
+
const inPanel =
|
|
665
|
+
relatedTarget instanceof Node &&
|
|
666
|
+
(listRef.current?.contains(relatedTarget) === true ||
|
|
667
|
+
actionRef.current?.contains(relatedTarget) === true);
|
|
668
|
+
if (!inPanel) closeTitles();
|
|
669
|
+
},
|
|
670
|
+
[closeTitles],
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
if (!visible) return null;
|
|
674
|
+
const baseLevel = Math.min(...items.map((item) => item.level));
|
|
675
|
+
const railStyle = {
|
|
676
|
+
// The nav box is [panel][gap][dock]; its left edge sits a panel and a
|
|
677
|
+
// gap left of the dock so the surface opens over the prose margin.
|
|
678
|
+
left: `${railGeometry.left - PANEL_GAP - PANEL_WIDTH}px`,
|
|
679
|
+
top: `${railGeometry.top}px`,
|
|
680
|
+
'--pn-contents-dock-width': `${DOCK_WIDTH}px`,
|
|
681
|
+
'--pn-contents-panel-gap': `${PANEL_GAP}px`,
|
|
682
|
+
'--pn-contents-panel-width': `${PANEL_WIDTH}px`,
|
|
683
|
+
} as CSSProperties;
|
|
684
|
+
|
|
685
|
+
// Layering: the rail is document chrome and renders in place, inside the
|
|
686
|
+
// document pane, not portalled to <body>. A host keeps the pane its own
|
|
687
|
+
// stacking context (an `isolate` ancestor) at the document level, so the
|
|
688
|
+
// host's floaters and overlays cover the rail, and inside the pane the
|
|
689
|
+
// diagram lightbox (z-50) covers it too. z-index 20 (theme.css) keeps it
|
|
690
|
+
// above the read surface.
|
|
691
|
+
return (
|
|
692
|
+
<nav
|
|
693
|
+
ref={railRef}
|
|
694
|
+
className="pn-contents-rail"
|
|
695
|
+
aria-label="Document contents"
|
|
696
|
+
data-revealed={titlesVisible ? '' : undefined}
|
|
697
|
+
style={railStyle}
|
|
698
|
+
onPointerLeave={() => setInteractionIndex(null)}
|
|
699
|
+
>
|
|
700
|
+
<div className="pn-contents-rail-dock" aria-hidden="true" />
|
|
701
|
+
<div className="pn-contents-rail-surface" aria-hidden="true" />
|
|
702
|
+
<div className="pn-contents-rail-title" aria-hidden="true">
|
|
703
|
+
Contents
|
|
704
|
+
</div>
|
|
705
|
+
<div ref={listRef} className="pn-contents-rail-list" role="list">
|
|
706
|
+
{renderedItemIndexes.map((index, markerPosition) => {
|
|
707
|
+
const item = items[index];
|
|
708
|
+
if (item === undefined) return null;
|
|
709
|
+
const current = titlesVisible
|
|
710
|
+
? index === activeIndex
|
|
711
|
+
: markerPosition === collapsedActiveSlot;
|
|
712
|
+
const highlighted = index === interactionIndex;
|
|
713
|
+
return (
|
|
714
|
+
<button
|
|
715
|
+
key={item.key}
|
|
716
|
+
ref={(node) => {
|
|
717
|
+
markerRefs.current[index] = node;
|
|
718
|
+
}}
|
|
719
|
+
type="button"
|
|
720
|
+
className="pn-contents-rail-marker"
|
|
721
|
+
data-contents-index={index}
|
|
722
|
+
data-current={current ? '' : undefined}
|
|
723
|
+
data-highlighted={highlighted ? '' : undefined}
|
|
724
|
+
data-level={item.level}
|
|
725
|
+
data-depth={Math.min(3, Math.max(0, item.level - baseLevel))}
|
|
726
|
+
aria-label={
|
|
727
|
+
titlesVisible
|
|
728
|
+
? `Go to ${item.breadcrumb}`
|
|
729
|
+
: `Open document contents at ${item.breadcrumb}`
|
|
730
|
+
}
|
|
731
|
+
aria-expanded={titlesVisible}
|
|
732
|
+
aria-current={current ? 'location' : undefined}
|
|
733
|
+
tabIndex={current ? 0 : -1}
|
|
734
|
+
onClick={(event) => {
|
|
735
|
+
if (!titlesVisible) {
|
|
736
|
+
openTitles(index);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (event.detail === 0) navigateTo(index, false);
|
|
740
|
+
}}
|
|
741
|
+
onFocus={() => {
|
|
742
|
+
if (titlesVisible) setInteractionIndex(index);
|
|
743
|
+
}}
|
|
744
|
+
onBlur={(event) => closeWhenFocusLeaves(event.relatedTarget)}
|
|
745
|
+
onPointerEnter={(event) => {
|
|
746
|
+
if (titlesVisible && event.pointerType !== 'touch') {
|
|
747
|
+
setInteractionIndex(index);
|
|
748
|
+
}
|
|
749
|
+
}}
|
|
750
|
+
onPointerDown={(event) => onPointerDown(event, index)}
|
|
751
|
+
onPointerMove={onPointerMove}
|
|
752
|
+
onPointerUp={endScrub}
|
|
753
|
+
onPointerCancel={endScrub}
|
|
754
|
+
onKeyDown={(event) => onMarkerKeyDown(event, index)}
|
|
755
|
+
>
|
|
756
|
+
<span className="pn-contents-rail-tick" aria-hidden="true">
|
|
757
|
+
<span className="pn-contents-rail-line" />
|
|
758
|
+
</span>
|
|
759
|
+
<span className="pn-contents-rail-label" aria-hidden="true">
|
|
760
|
+
{item.label}
|
|
761
|
+
</span>
|
|
762
|
+
</button>
|
|
763
|
+
);
|
|
764
|
+
})}
|
|
765
|
+
</div>
|
|
766
|
+
{titlesVisible && panelAction !== undefined && (
|
|
767
|
+
<div
|
|
768
|
+
ref={actionRef}
|
|
769
|
+
className="pn-contents-rail-action"
|
|
770
|
+
onBlur={(event) => closeWhenFocusLeaves(event.relatedTarget)}
|
|
771
|
+
>
|
|
772
|
+
{panelAction}
|
|
773
|
+
</div>
|
|
774
|
+
)}
|
|
775
|
+
</nav>
|
|
776
|
+
);
|
|
777
|
+
}
|