@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,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
+ }
@@ -1,81 +1,64 @@
1
- import { useEffect, useState, useRef } from 'react';
2
- import { getScrollViewportIntersectionRoot } from './useScrollViewport';
1
+ import { useEffect, useState } from 'react';
2
+ import { CONTENTS_ACTIVATION_OFFSET } from '../utils/headingScrollSpy';
3
+ import { addScrollViewportListener, getScrollViewportRect } from './useScrollViewport';
3
4
 
4
- /**
5
- * Track which heading section is currently visible in the viewport
6
- * Uses Intersection Observer to detect when headings enter/leave view
7
- */
5
+ /** Track the heading at the reading line, even when a scroll skips whole sections. */
8
6
  export function useActiveSection(
9
7
  containerRef: React.RefObject<HTMLElement | null>,
10
8
  headingCount: number,
11
- /** Optional explicit scroll element. When provided, takes precedence over
12
- * containerRef.current and re-runs the effect when it changes — needed
13
- * for OverlayScrollArea, whose viewport is only available after mount. */
9
+ /** Optional explicit scroll element, available after OverlayScrollArea mounts. */
14
10
  scrollElement?: HTMLElement | null,
15
11
  ) {
16
12
  const [activeId, setActiveId] = useState<string | null>(null);
17
- const observerRef = useRef<IntersectionObserver | null>(null);
18
13
 
19
14
  useEffect(() => {
20
15
  const contentContainer = containerRef.current ?? scrollElement;
21
16
  const viewport = scrollElement ?? contentContainer;
22
17
  if (!contentContainer || !viewport) return;
23
-
24
- // Only document headings: a heading inside a question card's context has
25
- // no block id and is not a TOC section.
26
- const headings = contentContainer.querySelectorAll('[data-block-type="heading"][data-block-id]');
27
- if (headings.length === 0) return;
28
-
29
- // Track which headings are currently intersecting
30
- const intersectingHeadings = new Map<Element, boolean>();
31
-
32
- // Create observer with configuration to detect headings near top of viewport
33
- observerRef.current = new IntersectionObserver(
34
- (entries) => {
35
- // Update intersection state
36
- entries.forEach(entry => {
37
- intersectingHeadings.set(entry.target, entry.isIntersecting);
38
- });
39
-
40
- // Find the first intersecting heading (topmost in viewport)
41
- let topHeading: Element | null = null;
42
- let topPosition = Infinity;
43
-
44
- for (const [heading, isIntersecting] of intersectingHeadings) {
45
- if (isIntersecting) {
46
- const rect = heading.getBoundingClientRect();
47
- if (rect.top < topPosition) {
48
- topPosition = rect.top;
49
- topHeading = heading;
50
- }
51
- }
52
- }
53
-
54
- // Update active ID
55
- if (topHeading) {
56
- const blockId = topHeading.getAttribute('data-block-id');
57
- if (blockId) {
58
- setActiveId(blockId);
59
- }
60
- }
61
- },
62
- {
63
- root: getScrollViewportIntersectionRoot(viewport),
64
- rootMargin: '-80px 0px -80% 0px', // Activate when heading is near top
65
- threshold: [0, 0.1, 0.5, 1.0],
18
+
19
+ const refresh = () => {
20
+ // Read current nodes, not a count-keyed snapshot: another document can
21
+ // replace every heading while keeping the same count and block ids.
22
+ const headings = contentContainer.querySelectorAll<HTMLElement>(
23
+ '[data-block-type="heading"][data-block-id]',
24
+ );
25
+ const readingLine = getScrollViewportRect(viewport).top + CONTENTS_ACTIVATION_OFFSET;
26
+ let current: HTMLElement | null = null;
27
+ for (const heading of headings) {
28
+ if (heading.getClientRects().length === 0) continue;
29
+ current ??= heading;
30
+ if (heading.getBoundingClientRect().top <= readingLine) current = heading;
31
+ else break;
66
32
  }
67
- );
68
-
69
- // Observe all headings
70
- headings.forEach(heading => {
71
- observerRef.current?.observe(heading);
72
- });
73
-
74
- // Cleanup
33
+ setActiveId(current?.dataset.blockId ?? null);
34
+ };
35
+
36
+ let frame: number | null = null;
37
+ const scheduleRefresh = () => {
38
+ if (frame !== null) return;
39
+ frame = requestAnimationFrame(() => {
40
+ frame = null;
41
+ refresh();
42
+ });
43
+ };
44
+ refresh();
45
+ const removeScrollListener = addScrollViewportListener(viewport, scheduleRefresh);
46
+ window.addEventListener('resize', scheduleRefresh);
47
+ const resizeObserver = typeof ResizeObserver === 'undefined'
48
+ ? null : new ResizeObserver(scheduleRefresh);
49
+ resizeObserver?.observe(contentContainer);
50
+ if (viewport !== contentContainer) resizeObserver?.observe(viewport);
51
+ const mutationObserver = new MutationObserver(scheduleRefresh);
52
+ mutationObserver.observe(contentContainer, { childList: true, characterData: true, subtree: true });
53
+
75
54
  return () => {
76
- observerRef.current?.disconnect();
55
+ if (frame !== null) cancelAnimationFrame(frame);
56
+ removeScrollListener();
57
+ window.removeEventListener('resize', scheduleRefresh);
58
+ resizeObserver?.disconnect();
59
+ mutationObserver.disconnect();
77
60
  };
78
61
  }, [containerRef, headingCount, scrollElement]);
79
-
62
+
80
63
  return activeId;
81
64
  }
@@ -421,6 +421,17 @@ const excludedAncestor = (node: Node | null): HTMLElement | null => {
421
421
  return element?.closest<HTMLElement>(ANNOTATION_EXCLUDED_SELECTOR) ?? null;
422
422
  };
423
423
 
424
+ /**
425
+ * The whole range sits inside one `.annotation-exclude` subtree of `container`
426
+ * (an Inbox action item or question card under text comments, a diagram):
427
+ * nothing in it can be annotated, so the selection is left to the person.
428
+ * An excluded element around the container itself does not count.
429
+ */
430
+ export const rangeInsideExcluded = (container: HTMLElement, range: Range): boolean => {
431
+ const excluded = excludedAncestor(range.commonAncestorContainer);
432
+ return excluded !== null && excluded !== container && container.contains(excluded);
433
+ };
434
+
424
435
  /**
425
436
  * Move a range's start off any `.annotation-exclude` subtree it begins inside,
426
437
  * onto the first annotatable text position the range covers.
@@ -1508,6 +1519,9 @@ export function useAnnotationHighlighter({
1508
1519
  trimWhitespaceOnlyBoundaryNodes(args[1] as SelectedNodeLike[])) as SelectedNodesTap);
1509
1520
 
1510
1521
  highlighterRef.current = highlighter;
1522
+ // Whether the library's pointer-end handler is attached (`run()`), and whether this effect was cleaned up.
1523
+ let listening = false;
1524
+ let disposed = false;
1511
1525
 
1512
1526
  // Stored positions can resolve into chrome the reviewer could never have
1513
1527
  // selected; painting never enters such a subtree, so a boundary left there
@@ -1650,6 +1664,25 @@ export function useAnnotationHighlighter({
1650
1664
  // AROUND the document and whose end boundary is an empty <div> — the
1651
1665
  // exact shape that throws — so a containment bail here would hand that
1652
1666
  // selection straight to the serializer.
1667
+ if (rangeInsideExcluded(container, range)) {
1668
+ // Nothing here can be annotated (an Inbox action item or question
1669
+ // card under text comments, a diagram): the selection is the
1670
+ // person's to copy. The library's own pointer-end handler paints
1671
+ // nothing for it and then removes the DOM selection anyway, so it
1672
+ // sits this one event out (a listener removed during dispatch never
1673
+ // runs) and listens again on the next task.
1674
+ pendingRangeRunsRef.current = null;
1675
+ if (listening) {
1676
+ highlighter.stop();
1677
+ listening = false;
1678
+ window.setTimeout(() => {
1679
+ if (disposed) return;
1680
+ highlighter.run();
1681
+ listening = true;
1682
+ }, 0);
1683
+ }
1684
+ return;
1685
+ }
1653
1686
  if (isBlankQuote(selection.toString()) || !isSerializableRange(range)) {
1654
1687
  pendingRangeRunsRef.current = null;
1655
1688
  selection.removeAllRanges();
@@ -1676,7 +1709,10 @@ export function useAnnotationHighlighter({
1676
1709
  // person chooses its Comment (takeSelection); the library painting it at
1677
1710
  // touchend would end the system selection and its edit menu first.
1678
1711
  const isTouchPrimary = window.matchMedia('(pointer: coarse)').matches;
1679
- if (!(isTouchPrimary && hostComposesRef.current)) highlighter.run();
1712
+ if (!(isTouchPrimary && hostComposesRef.current)) {
1713
+ highlighter.run();
1714
+ listening = true;
1715
+ }
1680
1716
 
1681
1717
  const handleMathMouseDown = (event: MouseEvent) => {
1682
1718
  mouseDownMathRef.current = closestMathElement(event.target as Node, containerRef.current);
@@ -1773,6 +1809,8 @@ export function useAnnotationHighlighter({
1773
1809
  if (!sel || sel.isCollapsed || sel.rangeCount === 0) return false;
1774
1810
  if (!containerRef.current?.contains(sel.anchorNode)) return false;
1775
1811
  const range = sel.getRangeAt(0).cloneRange();
1812
+ // Nothing to annotate there: the system selection and its copy menu stay the person's.
1813
+ if (rangeInsideExcluded(containerRef.current, range)) return false;
1776
1814
  // Same fail-closed rule as the pointer-end guard (#881): touch
1777
1815
  // selections settle on block boundaries just as often.
1778
1816
  if (isBlankQuote(sel.toString()) || !isSerializableRange(range)) return false;
@@ -1810,6 +1848,7 @@ export function useAnnotationHighlighter({
1810
1848
  containerRef.current?.removeEventListener('mouseup', handleMathMouseUp, true);
1811
1849
  container.removeEventListener('mouseup', handlePointerEndCapture, true);
1812
1850
  container.removeEventListener('touchend', handlePointerEndCapture, true);
1851
+ disposed = true;
1813
1852
  highlighter.dispose();
1814
1853
  };
1815
1854
  }, [clearPendingSelection, enabled]);
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { useState, useCallback, useEffect, useMemo, useRef } from "react";
11
11
  import type { VaultNode } from "../types";
12
+ import { basenameFromPath } from "@plannotator/core/source-save";
12
13
  import type { WorkspaceStatusPayload } from "@plannotator/core/workspace-status-types";
13
14
 
14
15
  export interface DirState {
@@ -200,7 +201,7 @@ export function useFileBrowser(): UseFileBrowserReturn {
200
201
  }, []);
201
202
 
202
203
  const fetchTree = useCallback(async (dirPath: string, options: { quiet?: boolean } = {}) => {
203
- const name = dirPath.split("/").pop() || dirPath;
204
+ const name = basenameFromPath(dirPath);
204
205
 
205
206
  setDirs((prev) => {
206
207
  const exists = prev.find((d) => d.path === dirPath);
@@ -291,7 +292,7 @@ export function useFileBrowser(): UseFileBrowserReturn {
291
292
  const vaultDirs = prev.filter((d) => d.isVault);
292
293
  const regularDirs = directories.map((path) => ({
293
294
  path,
294
- name: path.split("/").pop() || path,
295
+ name: basenameFromPath(path),
295
296
  tree: [],
296
297
  // Keep initial roots loading until their first snapshot resolves. If
297
298
  // the SSE watcher starts before /api/reference/files finishes,
@@ -313,7 +314,7 @@ export function useFileBrowser(): UseFileBrowserReturn {
313
314
  }, []);
314
315
 
315
316
  const addVaultDir = useCallback(async (vaultPath: string) => {
316
- const name = vaultPath.split("/").pop() || vaultPath;
317
+ const name = basenameFromPath(vaultPath);
317
318
 
318
319
  // Atomically replace any existing vault dirs (handles vault path change without accumulating stale entries)
319
320
  setDirs((prev) => {
@@ -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
+ }