@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.
@@ -22,6 +22,17 @@ interface TableOfContentsProps {
22
22
  linkedDocFilepath?: string | null;
23
23
  onLinkedDocBack?: () => void;
24
24
  backLabel?: string;
25
+ /**
26
+ * The element a click scrolls. Default: the `ScrollViewportProvider`
27
+ * context's viewport (plan review). A host that draws the list outside the
28
+ * document's scroll area (the Inbox's comments column) names it here.
29
+ */
30
+ scrollViewport?: HTMLElement | null;
31
+ /**
32
+ * The deepest heading level listed. Default 3 (plan review's list); the
33
+ * contents rail's surface (`DocumentContents`) lists 1 to 4, as the rail does.
34
+ */
35
+ maxLevel?: number;
25
36
  }
26
37
 
27
38
  // The prototype's TOC is a FLAT list — heading depth is conveyed by indentation
@@ -43,7 +54,7 @@ function flattenToc(items: TocItem[]): TocItem[] {
43
54
  // H1 flush + near-full strength, H2/H3 indented and dimmed to muted. Active row
44
55
  // is a soft neutral surface tint (not a loud primary fill).
45
56
  function itemClasses(level: number, isActive: boolean): string {
46
- const indent = level <= 1 ? '' : level === 2 ? 'ml-3' : 'ml-6';
57
+ const indent = level <= 1 ? '' : level === 2 ? 'ml-3' : level === 3 ? 'ml-6' : 'ml-9';
47
58
  const tone = isActive
48
59
  ? 'bg-surface-1 text-foreground'
49
60
  : level <= 1
@@ -62,6 +73,8 @@ export function TableOfContents({
62
73
  linkedDocFilepath,
63
74
  onLinkedDocBack,
64
75
  backLabel,
76
+ scrollViewport: scrollViewportProp,
77
+ maxLevel = 3,
65
78
  }: TableOfContentsProps) {
66
79
  // Annotation count per section (kept — production feature).
67
80
  const annotationCounts = useMemo(
@@ -69,15 +82,16 @@ export function TableOfContents({
69
82
  [blocks, annotations]
70
83
  );
71
84
 
72
- // Build the hierarchy (filters to heading levels ≤ 3 and attaches counts),
73
- // then flatten to a plain list.
85
+ // Build the hierarchy (filters to heading levels ≤ maxLevel and attaches
86
+ // counts), then flatten to a plain list.
74
87
  const tocItems = useMemo(
75
- () => flattenToc(buildTocHierarchy(blocks, annotationCounts)),
76
- [blocks, annotationCounts]
88
+ () => flattenToc(buildTocHierarchy(blocks, annotationCounts, maxLevel)),
89
+ [blocks, annotationCounts, maxLevel]
77
90
  );
78
91
 
79
92
  // The real scroll element is the OverlayScrollArea viewport, not <main>.
80
- const scrollViewport = useScrollViewport();
93
+ const contextViewport = useScrollViewport();
94
+ const scrollViewport = scrollViewportProp === undefined ? contextViewport : scrollViewportProp;
81
95
 
82
96
  // Smooth scroll-to-heading, accounting for the sticky header.
83
97
  const handleNavigate = useCallback(
@@ -320,6 +320,10 @@ export interface ViewerProps {
320
320
  /** A small label over each picture tile's picture (see `QuestionBlock`
321
321
  * `pictureBadge`); absent: none. */
322
322
  questionPictureBadge?: (choice: { label: string; image: { alt: string; src: string } }) => string | null;
323
+ /** A picture tile's zoom, told its question's key and the choice's index (see
324
+ * `QuestionBlock` `onZoomChoice`). Absent: the zoom opens the picture
325
+ * through `onImageClick`, as before. */
326
+ onQuestionPictureZoom?: (key: string, index: number, url: string, alt: string) => void;
323
327
  /** Makes a question's "Records a decision" tag a toggle button
324
328
  * (`aria-pressed`); a click calls this with the question's key and the
325
329
  * next state, which the host stores and returns from
@@ -833,6 +837,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
833
837
  questionContractLines = false,
834
838
  questionPictureChoices = true,
835
839
  questionPictureBadge,
840
+ onQuestionPictureZoom,
836
841
  onToggleQuestionDecisionRecording,
837
842
  onOpenQuestionDecision,
838
843
  questionDecisionLocked,
@@ -1329,8 +1334,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1329
1334
  const container = containerRef.current;
1330
1335
  if (!container || !scrollViewport) return false;
1331
1336
 
1332
- const target = document.getElementById(anchor);
1333
- if (!target || !container.contains(target)) return false;
1337
+ const target = container.querySelector<HTMLElement>(`#${CSS.escape(anchor)}`);
1338
+ if (!target) return false;
1334
1339
 
1335
1340
  const documentHeader = stickyActions
1336
1341
  ? container.querySelector<HTMLElement>('[data-viewer-document-header]')
@@ -1980,6 +1985,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1980
1985
  questionContractLines={questionContractLines}
1981
1986
  questionPictureChoices={questionPictureChoices}
1982
1987
  questionPictureBadge={questionPictureBadge}
1988
+ onQuestionPictureZoom={onQuestionPictureZoom}
1983
1989
  />
1984
1990
  );
1985
1991
  // `textComments`: a question card's words are not commented on
@@ -271,6 +271,11 @@ export interface QuestionBlockProps {
271
271
  * answer, never read by the parser. Default absent, so every card draws as
272
272
  * before (the Plannotator Inbox's onboarding tour labels its pictures). */
273
273
  pictureBadge?: (choice: { label: string; image: { alt: string; src: string } }) => string | null;
274
+ /** The zoom button on a picture tile, told which question and which choice
275
+ * (its index in the question's choices) as well as the picture: a host can
276
+ * open a gallery of that question's choices (the Plannotator Inbox's option
277
+ * sets). Absent: the zoom calls `onImageClick(url, alt)` as before. */
278
+ onZoomChoice?: (key: string, index: number, url: string, alt: string) => void;
274
279
  }
275
280
 
276
281
  /** The picture tile's label (`pictureBadge`): a small dark chip over the picture's top-left corner. */
@@ -321,6 +326,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
321
326
  onNavigateAnchor,
322
327
  pictureChoices = true,
323
328
  pictureBadge,
329
+ onZoomChoice,
324
330
  }) => {
325
331
  const { question, number } = indexed;
326
332
  const resolveImage = useImageResolver();
@@ -880,7 +886,11 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
880
886
  name={groupName}
881
887
  readOnly={readOnly}
882
888
  onPick={pick}
883
- onZoom={onImageClick}
889
+ onZoom={
890
+ onZoomChoice
891
+ ? (url, alt, index) => onZoomChoice(question.key, index, url, alt)
892
+ : onImageClick
893
+ }
884
894
  onPictureError={markPictureFailed}
885
895
  heldTitle={pictureHeldTitle}
886
896
  renderText={inline}
@@ -1121,8 +1131,8 @@ export interface QuestionPictureTilesProps {
1121
1131
  readOnly?: boolean;
1122
1132
  /** The tile's radio or checkbox changed (the picture and the label pick through it). */
1123
1133
  onPick: (label: string, checked: boolean) => void;
1124
- /** The separate zoom button (absent: no zoom button). Never picks. */
1125
- onZoom?: (url: string, alt: string) => void;
1134
+ /** The separate zoom button (absent: no zoom button). Never picks. `index`: the choice's place in `choices`. */
1135
+ onZoom?: (url: string, alt: string, index: number) => void;
1126
1136
  onPictureError?: (url: string) => void;
1127
1137
  /** The host's reason a picture is held (its image resolver's `unavailable` title). */
1128
1138
  heldTitle?: (src: string) => string | null;
@@ -1256,7 +1266,7 @@ export const QuestionPictureTiles: React.FC<QuestionPictureTilesProps> = ({
1256
1266
  {shown && onZoom && (
1257
1267
  <button
1258
1268
  type="button"
1259
- onClick={() => onZoom(shown, alt)}
1269
+ onClick={() => onZoom(shown, alt, index)}
1260
1270
  aria-label={`Zoom: ${alt}`}
1261
1271
  className="annotation-exclude absolute right-1.5 top-1.5 grid h-7 w-7 place-items-center rounded-md bg-background/85 text-foreground shadow-sm ring-1 ring-border/70 before:absolute before:-inset-2 before:content-[''] hover:bg-background focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
1262
1272
  data-pinpoint-ignore=""
@@ -148,6 +148,20 @@ const MAX_VIM_COPY_TEXT_LENGTH = 2 * 1024 * 1024;
148
148
  */
149
149
  export const HTML_VIEWER_SANDBOX = "allow-scripts allow-modals";
150
150
 
151
+ /**
152
+ * The srcdoc frame's `sandbox` attribute: the `sandbox` prop's value as given,
153
+ * plus `allow-popups` when `hostNavigates` needs it and the value lacks it.
154
+ * Anything that is not a string (`undefined`, and also `null` from a host's
155
+ * unset config, which a destructuring default does not catch) becomes
156
+ * `HTML_VIEWER_SANDBOX`, so the frame always carries a sandbox: React would
157
+ * drop a `null` attribute and run the page unsandboxed.
158
+ */
159
+ function srcdocSandbox(sandbox: unknown, hostNavigates: boolean): string {
160
+ const value = typeof sandbox === "string" ? sandbox : HTML_VIEWER_SANDBOX;
161
+ if (!hostNavigates || value.split(/\s+/).includes("allow-popups")) return value;
162
+ return value.trim() ? `${value} allow-popups` : "allow-popups";
163
+ }
164
+
151
165
  /** Default wait for the bridge's `ready` on the `bridgeScriptUrl` path. */
152
166
  export const DEFAULT_BRIDGE_READY_TIMEOUT_MS = 5000;
153
167
 
@@ -373,6 +387,26 @@ export interface HtmlViewerProps {
373
387
  * still pins. Absent → unchanged.
374
388
  */
375
389
  hostNavigates?: boolean;
390
+ /**
391
+ * The srcdoc frame's whole `sandbox` attribute value, for a host whose
392
+ * documents must not open dialogs: `"allow-scripts"` keeps a shared page's
393
+ * `alert`, `confirm` and `prompt` from drawing over the host's origin. Used
394
+ * as given, except that `hostNavigates` still adds `allow-popups`, which it
395
+ * needs. Never add `allow-same-origin` beside `allow-scripts`: the page
396
+ * could then remove its own sandbox. Live-app (`src`) mode has no sandbox
397
+ * and ignores this. Absent, or any non-string such as `null` →
398
+ * `HTML_VIEWER_SANDBOX` (`"allow-scripts allow-modals"`), unchanged.
399
+ *
400
+ * `""` (or only spaces) is kept as given: the strictest frame, with no
401
+ * scripts, so the annotation bridge never starts and the page cannot be
402
+ * annotated. It never falls back to the wider default.
403
+ *
404
+ * Read when the frame loads a document: browsers apply a changed `sandbox`
405
+ * only on the frame's next navigation (a new `rawHtml`, for example). To
406
+ * apply a new value to the document already shown, remount the viewer (for
407
+ * example, key it on the value).
408
+ */
409
+ sandbox?: string;
376
410
  }
377
411
 
378
412
  /**
@@ -429,6 +463,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
429
463
  bridgeErrorDisplay = "banner",
430
464
  onHostDraft,
431
465
  hostNavigates = false,
466
+ sandbox = HTML_VIEWER_SANDBOX,
432
467
  },
433
468
  ref,
434
469
  ) => {
@@ -1295,7 +1330,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1295
1330
  same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}
1296
1331
  <iframe
1297
1332
  ref={iframeRef}
1298
- {...(src ? { src } : { srcDoc: srcdoc, sandbox: HTML_VIEWER_SANDBOX + (hostNavigates ? " allow-popups" : "") })}
1333
+ {...(src ? { src } : { srcDoc: srcdoc, sandbox: srcdocSandbox(sandbox, hostNavigates) })}
1299
1334
  style={{
1300
1335
  width: "100%",
1301
1336
  height: fullViewport ? "100%" : `${iframeHeight}px`,
@@ -147,6 +147,17 @@ export function atFit(view: ZoomViewport, limits: ZoomLimits | number): boolean
147
147
  return Math.abs(view.scale - fit) < 1e-3;
148
148
  }
149
149
 
150
+ /**
151
+ * Whether the part of an image on screen (`visibleBox`, its own pixels) can
152
+ * still pan sideways: `1` shows more of its right (the finger moving left),
153
+ * `-1` more of its left. A swipe that way pans first, and steps once the
154
+ * image is at its edge (approval "inbox-image-swipe").
155
+ */
156
+ export function canPanToward(box: Pick<NaturalBox, 'x' | 'w'> | null, naturalWidth: number, dir: 1 | -1): boolean {
157
+ if (!box || !(naturalWidth > 0)) return false;
158
+ return dir > 0 ? box.x + box.w < naturalWidth - 1 : box.x > 1;
159
+ }
160
+
150
161
  /** The share of a stage that leaves `padding` CSS pixels on every side (at least a tenth). */
151
162
  export function paddingInset(stage: ZoomSize, padding: number): ZoomInset {
152
163
  if (!valid(stage) || padding <= 0) return FULL;
@@ -0,0 +1,337 @@
1
+ /*
2
+ * @plannotator/ui/contents-rail.css: the document contents rail
3
+ * (components/MarkdownContentsRail.tsx). Its own stylesheet, NOT part of
4
+ * theme.css or styles.css: import it OUTSIDE any CSS cascade layer, after the
5
+ * host's base reset. A layered copy loses to a later unlayered reset (the
6
+ * marker's margin-inline-start: auto, the tick, title and label paddings and
7
+ * margins, the depth indents), and the ticks then sit over the prose.
8
+ *
9
+ * Moved from the Workspaces app's `ws-markdown-contents-*` rules (approved
10
+ * 2026-10-03). A stable marker dock sits in the document's existing right
11
+ * padding without participating in layout. The Contents surface opens to its
12
+ * LEFT and may overlay the document, never a docked comments column. Pointer
13
+ * events stay on the real navigation buttons; selection and scrolling remain
14
+ * untouched. Plain CSS, no Tailwind. Tokens are the host's (Workspaces defines
15
+ * them all); each has a fallback.
16
+ */
17
+ @keyframes pn-contents-rail-enter {
18
+ from {
19
+ opacity: 0;
20
+ }
21
+ }
22
+
23
+ .pn-contents-rail {
24
+ --pn-contents-dock-width: 36px;
25
+ --pn-contents-panel-gap: 6px;
26
+ --pn-contents-panel-width: 256px;
27
+
28
+ position: fixed;
29
+ z-index: 20;
30
+ translate: 0 -50%;
31
+ width: calc(
32
+ var(--pn-contents-dock-width) + var(--pn-contents-panel-gap) + var(--pn-contents-panel-width)
33
+ );
34
+ pointer-events: none;
35
+ animation: pn-contents-rail-enter var(--motion-duration-fast, 120ms)
36
+ var(--ease-enter, cubic-bezier(0.22, 1, 0.36, 1)) both;
37
+ }
38
+
39
+ .pn-contents-rail-dock {
40
+ position: absolute;
41
+ top: 0;
42
+ right: 0;
43
+ bottom: 0;
44
+ width: var(--pn-contents-dock-width);
45
+ pointer-events: none;
46
+ }
47
+
48
+ /* The floating surface (Workspaces' `floating-surface`: popover fill, no drawn
49
+ * border, the edge is the hairline ring in the menu shadow). */
50
+ .pn-contents-rail-surface {
51
+ position: absolute;
52
+ top: -40px;
53
+ bottom: -8px;
54
+ left: 0;
55
+ width: var(--pn-contents-panel-width);
56
+ border-radius: var(--geometry-floating-radius, 10px);
57
+ background: var(--popover);
58
+ color: var(--popover-foreground);
59
+ box-shadow: var(
60
+ --shadow-menu,
61
+ 0 0 0 1px color-mix(in oklch, var(--foreground) 8%, transparent),
62
+ 0 12px 32px -8px color-mix(in oklch, var(--foreground) 22%, transparent)
63
+ );
64
+ transform: translateX(4px) scale(0.98);
65
+ transform-origin: right center;
66
+ opacity: 0;
67
+ pointer-events: none;
68
+ transition:
69
+ opacity var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1),
70
+ transform var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1);
71
+ }
72
+
73
+ /* The section eyebrow (Workspaces' EYEBROW: 12 px, weight 500). */
74
+ .pn-contents-rail-title {
75
+ position: absolute;
76
+ top: -32px;
77
+ left: 8px;
78
+ display: flex;
79
+ width: calc(var(--pn-contents-panel-width) - 16px);
80
+ height: 28px;
81
+ align-items: center;
82
+ padding-inline: 4px;
83
+ color: var(--foreground);
84
+ font-size: 12px;
85
+ font-weight: 500;
86
+ line-height: 20px;
87
+ transform: translateX(4px);
88
+ opacity: 0;
89
+ pointer-events: none;
90
+ transition:
91
+ opacity var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1),
92
+ transform var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1);
93
+ }
94
+
95
+ /* A host's one control at the right of the open panel's title (panelAction). */
96
+ .pn-contents-rail-action {
97
+ position: absolute;
98
+ z-index: 2;
99
+ top: -32px;
100
+ right: calc(var(--pn-contents-dock-width) + var(--pn-contents-panel-gap) + 8px);
101
+ display: flex;
102
+ height: 28px;
103
+ align-items: center;
104
+ pointer-events: auto;
105
+ }
106
+
107
+ .pn-contents-rail[data-revealed] .pn-contents-rail-surface,
108
+ .pn-contents-rail[data-revealed] .pn-contents-rail-title {
109
+ transform: translateX(0) scale(1);
110
+ opacity: 1;
111
+ }
112
+
113
+ .pn-contents-rail[data-revealed] .pn-contents-rail-title {
114
+ transition-delay: var(--motion-duration-basic, 150ms), var(--motion-duration-basic, 150ms);
115
+ }
116
+
117
+ .pn-contents-rail-list {
118
+ position: relative;
119
+ z-index: 1;
120
+ width: 100%;
121
+ /* The closed rail renders at most ten sampled document spans, so it never
122
+ * needs an internal scroll position. Shorter maps retain their natural
123
+ * height and stay centered by the rail's fixed midpoint anchor. */
124
+ max-height: 160px;
125
+ overflow-x: hidden;
126
+ overflow-y: hidden;
127
+ overscroll-behavior: contain;
128
+ scrollbar-width: none;
129
+ touch-action: none;
130
+ pointer-events: none;
131
+ transition: max-height var(--motion-duration-basic, 150ms) var(--ease-exit, cubic-bezier(0.4, 0, 0.2, 1));
132
+ }
133
+
134
+ .pn-contents-rail[data-revealed] .pn-contents-rail-list {
135
+ max-height: min(62vh, 560px);
136
+ overflow-y: auto;
137
+ transition-duration: var(--motion-duration-relaxed, 300ms);
138
+ transition-timing-function: var(--ease-enter, cubic-bezier(0.22, 1, 0.36, 1));
139
+ }
140
+
141
+ .pn-contents-rail-list::-webkit-scrollbar {
142
+ display: none;
143
+ }
144
+
145
+ .pn-contents-rail-marker {
146
+ --pn-contents-line-width: 26px;
147
+
148
+ position: relative;
149
+ display: flex;
150
+ width: var(--pn-contents-dock-width);
151
+ height: 16px;
152
+ flex-direction: row-reverse;
153
+ align-items: center;
154
+ justify-content: flex-start;
155
+ overflow: hidden;
156
+ margin-inline-start: auto;
157
+ padding: 0;
158
+ border: 0;
159
+ background: transparent;
160
+ cursor: pointer;
161
+ pointer-events: auto;
162
+ }
163
+
164
+ .pn-contents-rail[data-revealed] .pn-contents-rail-marker {
165
+ width: 100%;
166
+ overflow: visible;
167
+ }
168
+
169
+ .pn-contents-rail-tick {
170
+ display: flex;
171
+ width: var(--pn-contents-dock-width);
172
+ flex: none;
173
+ align-items: center;
174
+ justify-content: flex-start;
175
+ padding-inline-start: 4px;
176
+ }
177
+
178
+ .pn-contents-rail-line {
179
+ --marker-progress: 0;
180
+
181
+ display: block;
182
+ width: var(--pn-contents-line-width);
183
+ height: 2px;
184
+ transform: scaleX(calc(0.6154 + 0.3846 * var(--marker-progress)));
185
+ transform-origin: left center;
186
+ background: var(--muted-foreground);
187
+ opacity: 0.4;
188
+ pointer-events: none;
189
+ transition: none;
190
+ }
191
+
192
+ /* The frequently scanned closed map updates immediately. The local lens is
193
+ * motion reserved for the explicitly opened Contents state. */
194
+ .pn-contents-rail[data-revealed] .pn-contents-rail-line {
195
+ transition:
196
+ transform var(--motion-duration-basic, 150ms)
197
+ linear(0, 0.398 10%, 0.682 20%, 0.843 30%, 0.925 40%, 0.972 50%, 1.004 60%, 1.008 70%, 1.003 80%, 1),
198
+ opacity var(--motion-duration-basic, 150ms) ease,
199
+ background-color var(--motion-duration-basic, 150ms) ease;
200
+ }
201
+
202
+ /* H1:H2 is 12px:10px with the shorter line inset 2px toward the document, at
203
+ * a deliberately longer scale, then the same step for H3 and H4. */
204
+ .pn-contents-rail-marker[data-level="2"] {
205
+ --pn-contents-line-width: 22px;
206
+ }
207
+
208
+ .pn-contents-rail-marker[data-level="3"] {
209
+ --pn-contents-line-width: 18px;
210
+ }
211
+
212
+ .pn-contents-rail-marker[data-level="4"] {
213
+ --pn-contents-line-width: 14px;
214
+ }
215
+
216
+ .pn-contents-rail-marker[data-current] .pn-contents-rail-line {
217
+ background: var(--primary);
218
+ opacity: 0.68;
219
+ }
220
+
221
+ .pn-contents-rail-marker[data-highlighted] .pn-contents-rail-line,
222
+ .pn-contents-rail-marker:focus-visible .pn-contents-rail-line {
223
+ --marker-progress: 1;
224
+ background: var(--foreground);
225
+ opacity: 1;
226
+ }
227
+
228
+ .pn-contents-rail-marker:has(+ .pn-contents-rail-marker[data-highlighted]) .pn-contents-rail-line,
229
+ .pn-contents-rail-marker:has(+ .pn-contents-rail-marker:focus-visible) .pn-contents-rail-line,
230
+ .pn-contents-rail-marker[data-highlighted] + .pn-contents-rail-marker .pn-contents-rail-line,
231
+ .pn-contents-rail-marker:focus-visible + .pn-contents-rail-marker .pn-contents-rail-line {
232
+ --marker-progress: 0.7;
233
+ opacity: 0.78;
234
+ }
235
+
236
+ .pn-contents-rail-marker:has(+ .pn-contents-rail-marker + .pn-contents-rail-marker[data-highlighted]) .pn-contents-rail-line,
237
+ .pn-contents-rail-marker:has(+ .pn-contents-rail-marker + .pn-contents-rail-marker:focus-visible) .pn-contents-rail-line,
238
+ .pn-contents-rail-marker[data-highlighted] + .pn-contents-rail-marker + .pn-contents-rail-marker .pn-contents-rail-line,
239
+ .pn-contents-rail-marker:focus-visible + .pn-contents-rail-marker + .pn-contents-rail-marker .pn-contents-rail-line {
240
+ --marker-progress: 0.4;
241
+ opacity: 0.58;
242
+ }
243
+
244
+ .pn-contents-rail-marker:has(+ .pn-contents-rail-marker + .pn-contents-rail-marker + .pn-contents-rail-marker[data-highlighted]) .pn-contents-rail-line,
245
+ .pn-contents-rail-marker:has(+ .pn-contents-rail-marker + .pn-contents-rail-marker + .pn-contents-rail-marker:focus-visible) .pn-contents-rail-line,
246
+ .pn-contents-rail-marker[data-highlighted] + .pn-contents-rail-marker + .pn-contents-rail-marker + .pn-contents-rail-marker .pn-contents-rail-line,
247
+ .pn-contents-rail-marker:focus-visible + .pn-contents-rail-marker + .pn-contents-rail-marker + .pn-contents-rail-marker .pn-contents-rail-line {
248
+ --marker-progress: 0.2;
249
+ opacity: 0.46;
250
+ }
251
+
252
+ .pn-contents-rail-label {
253
+ display: block;
254
+ width: calc(var(--pn-contents-panel-width) - 16px);
255
+ height: 16px;
256
+ min-width: 0;
257
+ flex: none;
258
+ overflow: hidden;
259
+ margin-inline-end: calc(var(--pn-contents-panel-gap) + 8px);
260
+ padding-inline: 4px;
261
+ border-radius: var(--geometry-structure-radius, 4px);
262
+ background: transparent;
263
+ color: var(--muted-foreground);
264
+ font-size: inherit;
265
+ font-weight: 400;
266
+ line-height: 16px;
267
+ text-align: left;
268
+ text-overflow: ellipsis;
269
+ white-space: nowrap;
270
+ opacity: 0;
271
+ transform: translateX(-4px);
272
+ pointer-events: none;
273
+ transition:
274
+ opacity var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1),
275
+ transform var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1),
276
+ color var(--motion-duration-fast, 120ms) ease,
277
+ background-color var(--motion-duration-fast, 120ms) ease;
278
+ }
279
+
280
+ .pn-contents-rail-marker[data-depth="1"] .pn-contents-rail-label {
281
+ padding-inline-start: 16px;
282
+ }
283
+
284
+ .pn-contents-rail-marker[data-depth="2"] .pn-contents-rail-label {
285
+ padding-inline-start: 28px;
286
+ }
287
+
288
+ .pn-contents-rail-marker[data-depth="3"] .pn-contents-rail-label {
289
+ padding-inline-start: 40px;
290
+ }
291
+
292
+ .pn-contents-rail[data-revealed] .pn-contents-rail-label {
293
+ opacity: 1;
294
+ transform: translateX(0);
295
+ transition-delay: var(--motion-duration-basic, 150ms), var(--motion-duration-basic, 150ms), 0ms, 0ms;
296
+ }
297
+
298
+ .pn-contents-rail-marker[data-current] .pn-contents-rail-label {
299
+ color: var(--foreground);
300
+ }
301
+
302
+ .pn-contents-rail-marker[data-highlighted] .pn-contents-rail-label,
303
+ .pn-contents-rail-marker:focus-visible .pn-contents-rail-label {
304
+ background: var(--surface-hover, color-mix(in oklch, var(--foreground) 6%, transparent));
305
+ color: var(--foreground);
306
+ }
307
+
308
+ .pn-contents-rail:focus-within .pn-contents-rail-surface,
309
+ .pn-contents-rail:focus-within .pn-contents-rail-title,
310
+ .pn-contents-rail:focus-within .pn-contents-rail-label,
311
+ .pn-contents-rail:focus-within .pn-contents-rail-list {
312
+ transition-duration: 0ms;
313
+ transition-delay: 0ms;
314
+ }
315
+
316
+ @media (prefers-reduced-motion: reduce) {
317
+ .pn-contents-rail {
318
+ animation: none;
319
+ }
320
+ .pn-contents-rail-line {
321
+ transition: none;
322
+ }
323
+ .pn-contents-rail-surface,
324
+ .pn-contents-rail-title,
325
+ .pn-contents-rail-label,
326
+ .pn-contents-rail[data-revealed] .pn-contents-rail-surface,
327
+ .pn-contents-rail[data-revealed] .pn-contents-rail-title,
328
+ .pn-contents-rail[data-revealed] .pn-contents-rail-label {
329
+ transform: none;
330
+ transition: opacity var(--motion-duration-fast, 120ms) ease;
331
+ transition-delay: 0ms;
332
+ }
333
+ .pn-contents-rail-list,
334
+ .pn-contents-rail[data-revealed] .pn-contents-rail-list {
335
+ transition: none;
336
+ }
337
+ }
@@ -0,0 +1,77 @@
1
+ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
+ import { activeHeadingBinding, bindHeadings, type HeadingBinding } from '../utils/headingScrollSpy';
3
+
4
+ /**
5
+ * The block id of the heading being read in `scrollViewport`, by the contents
6
+ * rail's own scroll math (utils/headingScrollSpy): cached heading tops, read
7
+ * on every scroll frame, the last heading at the document's end. For a
8
+ * Contents list on the rail's surface (`DocumentContents`), so the two never
9
+ * disagree. Plan review's list keeps `useActiveSection`.
10
+ *
11
+ * Reads at once on mount (a sheet opened mid-document marks the section being
12
+ * read on its first paint), and re-binds when the document's elements change
13
+ * (an edit can replace the headings and keep their count) or resize. Before
14
+ * any heading is drawn the answer stays what it was.
15
+ */
16
+ export function useHeadingScrollSpy(
17
+ scrollViewport: HTMLElement | null,
18
+ blockIds: readonly string[],
19
+ ): string | null {
20
+ const idsKey = blockIds.join('\n');
21
+ // Stable while the ids are the same, whatever array the caller passes.
22
+ const ids = useMemo(() => (idsKey === '' ? [] : idsKey.split('\n')), [idsKey]);
23
+ const [activeIndex, setActiveIndex] = useState<number | null>(null);
24
+ const bindingsRef = useRef<readonly HeadingBinding[]>([]);
25
+
26
+ const refresh = useCallback(() => {
27
+ if (scrollViewport === null) return;
28
+ const binding = activeHeadingBinding(bindingsRef.current, scrollViewport);
29
+ if (binding !== null) setActiveIndex(binding.itemIndex);
30
+ }, [scrollViewport]);
31
+
32
+ useLayoutEffect(() => {
33
+ setActiveIndex(null);
34
+ bindingsRef.current = [];
35
+ if (scrollViewport === null || ids.length === 0) return;
36
+ const rebind = () => {
37
+ bindingsRef.current = bindHeadings(scrollViewport, ids);
38
+ refresh();
39
+ };
40
+ rebind();
41
+
42
+ let scrollFrame: number | null = null;
43
+ const onScroll = () => {
44
+ if (scrollFrame !== null) return;
45
+ scrollFrame = requestAnimationFrame(() => {
46
+ scrollFrame = null;
47
+ refresh();
48
+ });
49
+ };
50
+ let bindFrame: number | null = null;
51
+ const scheduleRebind = () => {
52
+ if (bindFrame !== null) cancelAnimationFrame(bindFrame);
53
+ bindFrame = requestAnimationFrame(() => {
54
+ bindFrame = null;
55
+ rebind();
56
+ });
57
+ };
58
+ scrollViewport.addEventListener('scroll', onScroll, { passive: true });
59
+ window.addEventListener('resize', scheduleRebind);
60
+ const resizeObserver =
61
+ typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(scheduleRebind);
62
+ resizeObserver?.observe(scrollViewport);
63
+ const mutationObserver =
64
+ typeof MutationObserver === 'undefined' ? null : new MutationObserver(scheduleRebind);
65
+ mutationObserver?.observe(scrollViewport, { childList: true, subtree: true });
66
+ return () => {
67
+ if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
68
+ if (bindFrame !== null) cancelAnimationFrame(bindFrame);
69
+ scrollViewport.removeEventListener('scroll', onScroll);
70
+ window.removeEventListener('resize', scheduleRebind);
71
+ resizeObserver?.disconnect();
72
+ mutationObserver?.disconnect();
73
+ };
74
+ }, [ids, refresh, scrollViewport]);
75
+
76
+ return activeIndex === null ? null : (ids[activeIndex] ?? null);
77
+ }