@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.
- package/HANDOFF.md +102 -6
- package/README.md +128 -3
- package/components/AnnotationPanel.tsx +2 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +4 -1
- package/components/CodeFilePopout.tsx +6 -4
- package/components/CodeSnippetPreview.tsx +174 -0
- package/components/ComposerTokenTextarea.tsx +43 -2
- package/components/DeferredMarkdownContentsRail.tsx +38 -0
- package/components/DiffFileTree.tsx +7 -6
- package/components/DocBadges.tsx +47 -35
- package/components/DocumentContents.tsx +74 -0
- package/components/ImageLightbox.tsx +121 -63
- package/components/ImageZoomStage.tsx +32 -4
- package/components/InlineMarkdown.tsx +15 -150
- package/components/MarkdownContentsRail.tsx +777 -0
- package/components/Settings.tsx +2 -1
- package/components/TableOfContents.tsx +22 -7
- package/components/Viewer.tsx +51 -19
- package/components/blocks/QuestionBlock.tsx +14 -4
- package/components/chat/ChatComposer.tsx +192 -0
- package/components/chat/ChatFrame.tsx +524 -0
- package/components/chat/ChatTranscript.tsx +422 -0
- package/components/chat/chatMarkdown.ts +436 -0
- package/components/chat/chatRecipes.tsx +39 -0
- package/components/chat/chatWindowStore.ts +196 -0
- package/components/chat/index.ts +72 -0
- package/components/diagram/DiagramCanvas.tsx +7 -0
- package/components/diagram/DiagramComposer.tsx +15 -3
- package/components/diagram/DiagramViewer.tsx +1 -0
- package/components/docBadgesWidth.ts +29 -0
- package/components/html-viewer/HtmlViewer.tsx +36 -1
- package/components/imageZoom.ts +21 -0
- package/components/inbox-thread/FileName.tsx +118 -0
- package/components/inbox-thread/GuideCard.tsx +39 -0
- package/components/inbox-thread/ReaskNote.tsx +25 -0
- package/components/inbox-thread/SkillsLine.tsx +40 -0
- package/components/inbox-thread/Tip.tsx +105 -0
- package/components/inbox-thread/icons.tsx +262 -0
- package/components/inbox-thread/reask.ts +222 -0
- package/contents-rail.css +337 -0
- package/hooks/useActiveSection.ts +46 -63
- package/hooks/useAnnotationHighlighter.ts +40 -1
- package/hooks/useFileBrowser.ts +4 -3
- package/hooks/useHeadingScrollSpy.ts +77 -0
- package/hooks/useHoverPreview.ts +94 -0
- package/hooks/usePrefersReducedMotion.ts +23 -0
- package/hooks/useSwipeStep.ts +597 -0
- package/inbox-thread.css +4542 -0
- package/package.json +13 -4
- package/styles.css +1 -1
- package/theme.css +1 -0
- package/themes/tokyo-night-moon.css +34 -0
- package/utils/aiChatFormat.ts +32 -11
- package/utils/annotationHelpers.ts +32 -4
- package/utils/blockParse.ts +45 -0
- package/utils/headingScrollSpy.ts +105 -0
- package/utils/syntaxTheme.ts +1 -0
- 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
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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))
|
|
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]);
|
package/hooks/useFileBrowser.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
+
}
|