@plannotator/ui 0.54.0 → 0.56.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.
Files changed (59) hide show
  1. package/HANDOFF.md +102 -6
  2. package/README.md +128 -3
  3. package/components/AnnotationPanel.tsx +2 -1
  4. package/components/AttachmentsButton.tsx +1 -1
  5. package/components/BlockRenderer.tsx +4 -1
  6. package/components/CodeFilePopout.tsx +6 -4
  7. package/components/CodeSnippetPreview.tsx +174 -0
  8. package/components/ComposerTokenTextarea.tsx +43 -2
  9. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  10. package/components/DiffFileTree.tsx +7 -6
  11. package/components/DocBadges.tsx +47 -35
  12. package/components/DocumentContents.tsx +74 -0
  13. package/components/ImageLightbox.tsx +121 -63
  14. package/components/ImageZoomStage.tsx +32 -4
  15. package/components/InlineMarkdown.tsx +15 -150
  16. package/components/MarkdownContentsRail.tsx +777 -0
  17. package/components/Settings.tsx +2 -1
  18. package/components/TableOfContents.tsx +22 -7
  19. package/components/Viewer.tsx +51 -19
  20. package/components/blocks/QuestionBlock.tsx +14 -4
  21. package/components/chat/ChatComposer.tsx +192 -0
  22. package/components/chat/ChatFrame.tsx +524 -0
  23. package/components/chat/ChatTranscript.tsx +422 -0
  24. package/components/chat/chatMarkdown.ts +436 -0
  25. package/components/chat/chatRecipes.tsx +39 -0
  26. package/components/chat/chatWindowStore.ts +196 -0
  27. package/components/chat/index.ts +72 -0
  28. package/components/diagram/DiagramCanvas.tsx +7 -0
  29. package/components/diagram/DiagramComposer.tsx +15 -3
  30. package/components/diagram/DiagramViewer.tsx +1 -0
  31. package/components/docBadgesWidth.ts +29 -0
  32. package/components/html-viewer/HtmlViewer.tsx +36 -1
  33. package/components/imageZoom.ts +21 -0
  34. package/components/inbox-thread/FileName.tsx +118 -0
  35. package/components/inbox-thread/GuideCard.tsx +39 -0
  36. package/components/inbox-thread/ReaskNote.tsx +25 -0
  37. package/components/inbox-thread/SkillsLine.tsx +40 -0
  38. package/components/inbox-thread/Tip.tsx +105 -0
  39. package/components/inbox-thread/icons.tsx +262 -0
  40. package/components/inbox-thread/reask.ts +222 -0
  41. package/contents-rail.css +337 -0
  42. package/hooks/useActiveSection.ts +46 -63
  43. package/hooks/useAnnotationHighlighter.ts +40 -1
  44. package/hooks/useFileBrowser.ts +4 -3
  45. package/hooks/useHeadingScrollSpy.ts +77 -0
  46. package/hooks/useHoverPreview.ts +94 -0
  47. package/hooks/usePrefersReducedMotion.ts +23 -0
  48. package/hooks/useSwipeStep.ts +597 -0
  49. package/inbox-thread.css +4542 -0
  50. package/package.json +13 -4
  51. package/styles.css +1 -1
  52. package/theme.css +1 -0
  53. package/themes/tokyo-night-moon.css +34 -0
  54. package/utils/aiChatFormat.ts +32 -11
  55. package/utils/annotationHelpers.ts +32 -4
  56. package/utils/blockParse.ts +45 -0
  57. package/utils/headingScrollSpy.ts +105 -0
  58. package/utils/syntaxTheme.ts +1 -0
  59. package/utils/themeRegistry.ts +11 -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
+ }