@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,597 @@
1
+ /**
2
+ * A one-finger swipe that steps between files or images on a touch screen
3
+ * (the Plannotator Inbox's phone viewer, approval "mobile-file-stepping",
4
+ * and its images, approval "inbox-image-swipe"; ui's `ImageLightbox` with its
5
+ * `swipe` prop). The thing on screen follows the finger and the neighbour
6
+ * comes in from the edge; past a third of the width, or on a flick, it
7
+ * steps; short of that it springs back. At the first and last item it
8
+ * resists and, released far enough, the host says it is the end. Under
9
+ * reduced motion nothing follows the finger: the step happens on release.
10
+ *
11
+ * What the finger is on decides too (`sidewaysOwner`): a block that still
12
+ * scrolls sideways in the swipe's direction (a wide code line, a table, a
13
+ * scroller inside a shadow root), a field, a video, the diagram canvas or a
14
+ * frame keeps the touch, and so does whatever the host says keeps it
15
+ * (`keeps`: a zoomed image that can still pan that way). Selected text keeps
16
+ * it, and so does a second finger. The swipe is decided once the finger has
17
+ * moved `SWIPE_DECIDE_PX`, and only when it moves `SWIPE_RATIO` times more
18
+ * sideways than up or down, so scrolling a document is never taken.
19
+ *
20
+ * Optional, for an image: a drag down closes (`dismiss`), and the neighbour
21
+ * can be drawn whole by the host (`peek`), in which case a committed step
22
+ * leaves the host's picture in place until the new image is shown
23
+ * (`settle`), with no fade in. The touches are heard on `area`; what moves
24
+ * can be something inside it (`move`: the picture, while its controls stay
25
+ * put), so `area` itself stays where it is and a finger that comes back
26
+ * while the last step is still sliding is heard: that step lands at once
27
+ * and the new swipe starts from it.
28
+ *
29
+ * Moved here from the Inbox's `fileSwipe.ts` (which re-exports it) so ui's
30
+ * lightbox steps the same way; the numbers did not change.
31
+ */
32
+
33
+ import { useEffect, useMemo, useRef, useSyncExternalStore, type RefObject } from 'react';
34
+
35
+ /** The finger moves this far before the swipe is decided either way. */
36
+ export const SWIPE_DECIDE_PX = 10;
37
+ /** And must move this many times more sideways than up or down to be a swipe (or more down than sideways to close). */
38
+ export const SWIPE_RATIO = 1.5;
39
+ /** Released past this share of the width, the swipe steps. */
40
+ export const SWIPE_COMMIT_SHARE = 1 / 3;
41
+ /** A flick: this fast (px/ms) in the swipe's direction, and at least `SWIPE_FLICK_MIN_PX` across, steps too. */
42
+ export const SWIPE_FLICK_SPEED = 0.5;
43
+ export const SWIPE_FLICK_MIN_PX = 40;
44
+ /** Past the first or last item the thing follows only this share of the finger. */
45
+ export const SWIPE_END_RESIST = 1 / 3;
46
+ /** The item leaves (and the next one comes in) in this long. */
47
+ export const SWIPE_SLIDE_MS = 180;
48
+ /** Short of the line, it springs back in this long. */
49
+ export const SWIPE_SPRING_MS = 260;
50
+ /** The gap between an image and the neighbour that peeks in beside it (`peek`). */
51
+ export const SWIPE_PEEK_GAP = 16;
52
+ /** A drag down closes once released past this share of the height (or on a downward flick). */
53
+ export const SWIPE_DISMISS_SHARE = 0.15;
54
+ /** The backdrop is fully faded once the drag down has gone this share of the height. */
55
+ export const SWIPE_DISMISS_FADE_SHARE = 1 / 3;
56
+ /** The host's picture of the new item stays at most this long after a step, should the new item never say it is shown. */
57
+ export const SWIPE_HANDOFF_MAX_MS = 700;
58
+
59
+ /** 1: the next item (the finger moves left); -1: the previous one. */
60
+ export type SwipeDir = 1 | -1;
61
+
62
+ export const swipeDirOf = (dx: number): SwipeDir => (dx < 0 ? 1 : -1);
63
+
64
+ /** Once the finger has moved (`mx`, `my` from where it went down): wait, a swipe, or the page's own (a scroll). */
65
+ export function decideSwipe(mx: number, my: number): 'wait' | 'swipe' | 'native' {
66
+ if (Math.abs(mx) < SWIPE_DECIDE_PX && Math.abs(my) < SWIPE_DECIDE_PX) return 'wait';
67
+ return Math.abs(mx) > SWIPE_RATIO * Math.abs(my) ? 'swipe' : 'native';
68
+ }
69
+
70
+ /** A drag that is mostly down (`my` from where the finger went down): a drag to close. */
71
+ export function isDragDown(mx: number, my: number): boolean {
72
+ return my >= SWIPE_DECIDE_PX && my > SWIPE_RATIO * Math.abs(mx);
73
+ }
74
+
75
+ /** Released at `dx` (raw finger travel) with speed `vx` (px/ms): whether it steps. */
76
+ export function swipeCommits(dx: number, width: number, vx: number): boolean {
77
+ if (Math.abs(dx) > width * SWIPE_COMMIT_SHARE) return true;
78
+ return Math.abs(vx) > SWIPE_FLICK_SPEED && Math.abs(dx) > SWIPE_FLICK_MIN_PX && Math.sign(vx) === Math.sign(dx);
79
+ }
80
+
81
+ /** Released `dy` down (with speed `vy`, px/ms) on a stage `height` tall: whether it closes. */
82
+ export function dismissCommits(dy: number, height: number, vy: number): boolean {
83
+ if (dy > height * SWIPE_DISMISS_SHARE) return true;
84
+ return vy > SWIPE_FLICK_SPEED && dy > SWIPE_FLICK_MIN_PX;
85
+ }
86
+
87
+ /**
88
+ * What keeps a touch itself, by the elements the finger went down on. A host
89
+ * adds its own (`SwipeStepOptions.owners`: the Inbox adds the diagram canvas,
90
+ * which ui's document Viewer must not reach statically).
91
+ */
92
+ const TOUCH_OWNERS = 'video, audio, input, textarea, select, [contenteditable=""], [contenteditable="true"], iframe, [data-claims-arrows="true"]';
93
+
94
+ /**
95
+ * Why the touch is not the swipe's, or null when it may be: `owner` (a field,
96
+ * a video, a frame, the diagram canvas), `sideways` (a block that can still
97
+ * scroll that way). `path` is the touchstart's composed path (through shadow
98
+ * roots); `dir` the way the swipe would step.
99
+ */
100
+ export function sidewaysOwner(path: readonly EventTarget[], dir: SwipeDir, owners?: string): 'owner' | 'sideways' | null {
101
+ const own = owners ? `${TOUCH_OWNERS}, ${owners}` : TOUCH_OWNERS;
102
+ for (const node of path) {
103
+ if (typeof Element === 'undefined' || !(node instanceof Element)) continue;
104
+ if (node === document.documentElement || node === document.body) continue;
105
+ if (node.matches(own)) return 'owner';
106
+ if (node.scrollWidth > node.clientWidth + 1) {
107
+ const overflow = getComputedStyle(node).overflowX;
108
+ if (overflow === 'auto' || overflow === 'scroll') {
109
+ // At its edge in the swipe's direction the block has nothing more to show: the swipe may step.
110
+ // Next (the finger moves left) scrolls the block right, so it is at its edge when scrolled to its end.
111
+ const max = node.scrollWidth - node.clientWidth;
112
+ const atEdge = dir > 0 ? node.scrollLeft >= max - 1 : node.scrollLeft <= 1;
113
+ if (!atEdge) return 'sideways';
114
+ }
115
+ }
116
+ }
117
+ return null;
118
+ }
119
+
120
+ const textSelected = () => {
121
+ const selection = typeof window !== 'undefined' ? window.getSelection() : null;
122
+ return !!selection && !selection.isCollapsed && selection.toString().trim() !== '';
123
+ };
124
+
125
+ export const prefersReducedMotion = () => typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
126
+
127
+ /**
128
+ * Where a swipe is, for what the host draws beside it: which way, how far
129
+ * (`progress`, 0..1 of the width, raw finger travel), where the item is drawn
130
+ * (`offset`, px), and the phase: `drag` (under the finger), `slide` (leaving
131
+ * after a release; `ms` long), `back` (springing back; `ms` long) or
132
+ * `handoff` (stepped; the host's picture of the new item stays until
133
+ * `settle`). Nothing while no swipe is under way.
134
+ */
135
+ export interface SwipeProgress {
136
+ dir: SwipeDir;
137
+ progress: number;
138
+ offset: number;
139
+ phase: 'drag' | 'slide' | 'back' | 'handoff';
140
+ ms?: number;
141
+ }
142
+
143
+ interface ProgressStore {
144
+ get: () => SwipeProgress | null;
145
+ set: (next: SwipeProgress | null) => void;
146
+ subscribe: (listener: () => void) => () => void;
147
+ }
148
+
149
+ function createProgressStore(): ProgressStore {
150
+ let value: SwipeProgress | null = null;
151
+ const listeners = new Set<() => void>();
152
+ return {
153
+ get: () => value,
154
+ set: (next) => {
155
+ if (next === value) return;
156
+ if (next && value && next.dir === value.dir && next.phase === value.phase && Math.abs(next.progress - value.progress) < 0.005 && Math.abs(next.offset - value.offset) < 0.5) return;
157
+ value = next;
158
+ for (const listener of listeners) listener();
159
+ },
160
+ subscribe: (listener) => {
161
+ listeners.add(listener);
162
+ return () => listeners.delete(listener);
163
+ },
164
+ };
165
+ }
166
+
167
+ /** The swipe's progress, read by what the host draws beside it (the host never re-renders while the finger moves). */
168
+ export function useSwipeProgress(store: SwipeStep): SwipeProgress | null {
169
+ return useSyncExternalStore(store.subscribe, store.get, store.get);
170
+ }
171
+
172
+ /** Only the swipe's phase and direction, for what needs no redraw while the finger moves (it redraws when either changes). */
173
+ export function useSwipePhase(store: SwipeStep): Pick<SwipeProgress, 'dir' | 'phase'> | null {
174
+ const read = () => {
175
+ const progress = store.get();
176
+ return progress ? `${progress.phase}:${progress.dir}` : '';
177
+ };
178
+ const key = useSyncExternalStore(store.subscribe, read, read);
179
+ return useMemo(() => {
180
+ if (!key) return null;
181
+ const [phase, dir] = key.split(':');
182
+ return { phase: phase as SwipeProgress['phase'], dir: (dir === '1' ? 1 : -1) as SwipeDir };
183
+ }, [key]);
184
+ }
185
+
186
+ export interface SwipeStep extends Pick<ProgressStore, 'get' | 'subscribe'> {
187
+ /** The new item is shown: the host's picture of it (`peek`) can go. */
188
+ settle: () => void;
189
+ }
190
+
191
+ export interface SwipeStepOptions {
192
+ /** The swipe is on (a phone, full screen, two or more items, nothing open over it). */
193
+ enabled: boolean;
194
+ /** There is an item that way (else the item resists, and a release past the line asks `onStep` for the end note). */
195
+ canStep: (dir: SwipeDir) => boolean;
196
+ /** Step that way (also at an end: the host says it is the end). */
197
+ onStep: (dir: SwipeDir) => void;
198
+ /** Leaving now would raise a question (a typed comment): the item springs back and the step asks first. */
199
+ wouldAsk: () => boolean;
200
+ /** More elements that keep a touch themselves (a selector, added to fields, videos and frames). */
201
+ owners?: string;
202
+ /**
203
+ * What follows the finger, when it is not `area` itself (`area` is where the touches are heard): for a swipe that
204
+ * way (`step`) or a drag down (`dismiss`). Read once the swipe is decided, and again when the finger turns back
205
+ * the other way. The elements stay those until the swipe has landed; with `peek`, a step resets them after
206
+ * `onStep` (the host's picture covers the change), so they may be the item's own. Without `peek` the next item
207
+ * fades in on the same elements, so they must outlive the step. Absent, null or empty: `area` moves.
208
+ */
209
+ move?: (gesture: 'step' | 'dismiss', dir: SwipeDir) => readonly (HTMLElement | null | undefined)[] | null;
210
+ /** The item keeps this touch itself (a zoomed image that can still pan that way): no swipe. Absent: nothing does. */
211
+ keeps?: (dir: SwipeDir) => boolean;
212
+ /**
213
+ * The host draws the neighbour that way whole, beside the item (`SWIPE_PEEK_GAP` apart): a committed step slides
214
+ * the item a full width and the gap, steps, and leaves the host's picture in place (`handoff`) until `settle`
215
+ * (or `SWIPE_HANDOFF_MAX_MS`), with no fade in. Absent or false: the next item fades in after the step.
216
+ */
217
+ peek?: (dir: SwipeDir) => boolean;
218
+ /** A drag down closes. Absent: a vertical drag is the page's own. */
219
+ dismiss?: {
220
+ /** Whether a drag down may close now (an image at Fit that cannot pan down). */
221
+ can: () => boolean;
222
+ /** Close (as Back does). */
223
+ onDismiss: () => void;
224
+ /** How far the drag down has gone, 0..1 of a third of the height, to fade the backdrop; 0 when it ends. */
225
+ onProgress?: (share: number) => void;
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Wire the swipe onto `area`: touch listeners (touchmove not passive, so a
231
+ * decided swipe is never also a scroll), the item (`area`, or what `move`
232
+ * names) moved by `transform` only, and the progress for what the host draws
233
+ * beside it.
234
+ */
235
+ export function useSwipeStep(area: RefObject<HTMLElement | null>, options: SwipeStepOptions): SwipeStep {
236
+ const progressStore = useMemo(createProgressStore, []);
237
+ const optionsRef = useRef(options);
238
+ optionsRef.current = options;
239
+ const { enabled } = options;
240
+ const handoffTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
241
+ const store = useMemo<SwipeStep>(
242
+ () => ({
243
+ get: progressStore.get,
244
+ subscribe: progressStore.subscribe,
245
+ settle: () => {
246
+ if (progressStore.get()?.phase !== 'handoff') return;
247
+ if (handoffTimer.current) clearTimeout(handoffTimer.current);
248
+ handoffTimer.current = null;
249
+ progressStore.set(null);
250
+ },
251
+ }),
252
+ [progressStore],
253
+ );
254
+
255
+ useEffect(() => {
256
+ const el = area.current;
257
+ if (!el || !enabled) return;
258
+ let start: { x: number; y: number; path: EventTarget[] } | null = null;
259
+ let mode: 'idle' | 'swipe' | 'dismiss' | 'native' = 'idle';
260
+ let last = { x: 0, y: 0, t: 0 };
261
+ let vx = 0;
262
+ let vy = 0;
263
+ let raw = 0;
264
+ let rawY = 0;
265
+ let pickedDir: SwipeDir = 1;
266
+ const timers = new Set<ReturnType<typeof setTimeout>>();
267
+ let frame = 0;
268
+ /** What follows the finger: `area`, or what the host's `move` names. */
269
+ let moved: readonly HTMLElement[] = [el];
270
+ /** The area's size, read once the swipe is decided (never on every move). */
271
+ let size = { width: 1, height: 1 };
272
+ const measure = () => {
273
+ const rect = el.getBoundingClientRect();
274
+ size = { width: rect.width || 1, height: rect.height || 1 };
275
+ };
276
+ const width = () => size.width;
277
+ const height = () => size.height;
278
+ const reset = (nodes: readonly HTMLElement[]) => {
279
+ for (const node of nodes) {
280
+ node.style.transform = '';
281
+ node.style.transition = '';
282
+ node.style.opacity = '';
283
+ }
284
+ };
285
+ /** Point the swipe at what follows the finger for this gesture (the last one's are put back first). */
286
+ const pick = (gesture: 'step' | 'dismiss', dir: SwipeDir) => {
287
+ const named = optionsRef.current.move?.(gesture, dir)?.filter((node): node is HTMLElement => !!node) ?? [];
288
+ const next = named.length > 0 ? named : [el];
289
+ if (next.length !== moved.length || next.some((node, i) => node !== moved[i])) {
290
+ reset(moved.filter((node) => !next.includes(node)));
291
+ moved = next;
292
+ }
293
+ };
294
+ const draw = (offset: number, transition: string | null, opacity = '', axis: 'x' | 'y' = 'x') => {
295
+ const transform = offset ? (axis === 'x' ? `translateX(${offset}px)` : `translateY(${offset}px)`) : '';
296
+ for (const node of moved) {
297
+ node.style.transition = transition ?? 'none';
298
+ node.style.transform = transform;
299
+ node.style.opacity = opacity;
300
+ }
301
+ };
302
+ const unsetTransition = () => {
303
+ for (const node of moved) node.style.transition = '';
304
+ };
305
+ /**
306
+ * A step's slide that is still under way: what lands it. A touch that comes meanwhile lands it at once
307
+ * (`landNow`), so the new swipe starts from the item it brought in.
308
+ */
309
+ let sliding: { timer: ReturnType<typeof setTimeout>; land: (interrupted: boolean) => void } | null = null;
310
+ const slideThen = (ms: number, land: (interrupted: boolean) => void) => {
311
+ const timer = setTimeout(() => {
312
+ sliding = null;
313
+ land(false);
314
+ }, ms);
315
+ sliding = { timer, land };
316
+ };
317
+ const landNow = () => {
318
+ if (frame) {
319
+ // The next item still fading in after a step: in place at once.
320
+ cancelAnimationFrame(frame);
321
+ frame = 0;
322
+ draw(0, null, '');
323
+ unsetTransition();
324
+ }
325
+ if (!sliding) return;
326
+ const { timer, land } = sliding;
327
+ sliding = null;
328
+ clearTimeout(timer);
329
+ land(true);
330
+ };
331
+ const later = (ms: number, run: () => void) => {
332
+ const timer = setTimeout(() => {
333
+ timers.delete(timer);
334
+ run();
335
+ }, ms);
336
+ timers.add(timer);
337
+ };
338
+ /** Once a spring-back or slide-in has run, the item carries no inline transition (unless a new swipe took it meanwhile). */
339
+ const clearTransitionAfter = (ms: number) =>
340
+ later(ms, () => {
341
+ if (mode !== 'swipe' && mode !== 'dismiss') unsetTransition();
342
+ });
343
+ const fade = (share: number) => optionsRef.current.dismiss?.onProgress?.(share);
344
+ const springBack = () => {
345
+ const reduced = prefersReducedMotion();
346
+ const was = progressStore.get();
347
+ draw(0, reduced ? null : `transform ${SWIPE_SPRING_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`);
348
+ fade(0);
349
+ if (was && !reduced) {
350
+ progressStore.set({ dir: was.dir, progress: 0, offset: 0, phase: 'back', ms: SWIPE_SPRING_MS });
351
+ later(SWIPE_SPRING_MS, () => {
352
+ if (progressStore.get()?.phase === 'back') progressStore.set(null);
353
+ });
354
+ } else progressStore.set(null);
355
+ if (reduced) unsetTransition();
356
+ else clearTransitionAfter(SWIPE_SPRING_MS);
357
+ };
358
+ const onStart = (event: TouchEvent) => {
359
+ if (event.touches.length !== 1) {
360
+ if (mode === 'swipe' || mode === 'dismiss') springBack();
361
+ start = null;
362
+ mode = 'native';
363
+ return;
364
+ }
365
+ // The last step still sliding in: it lands now, and this swipe starts from the item it brought.
366
+ landNow();
367
+ const touch = event.touches[0]!;
368
+ start = { x: touch.clientX, y: touch.clientY, path: event.composedPath() };
369
+ mode = 'idle';
370
+ last = { x: touch.clientX, y: touch.clientY, t: event.timeStamp };
371
+ vx = 0;
372
+ vy = 0;
373
+ raw = 0;
374
+ rawY = 0;
375
+ };
376
+ const onMove = (event: TouchEvent) => {
377
+ if (!start || mode === 'native') return;
378
+ if (event.touches.length !== 1) {
379
+ if (mode === 'swipe' || mode === 'dismiss') springBack();
380
+ mode = 'native';
381
+ return;
382
+ }
383
+ const touch = event.touches[0]!;
384
+ const mx = touch.clientX - start.x;
385
+ const my = touch.clientY - start.y;
386
+ if (mode === 'idle') {
387
+ const decided = decideSwipe(mx, my);
388
+ if (decided === 'wait') return;
389
+ const { dismiss, keeps } = optionsRef.current;
390
+ if (decided === 'native') {
391
+ // Mostly down: a drag to close, where the host allows one now; anything else is the page's own.
392
+ if (dismiss && isDragDown(mx, my) && !textSelected() && sidewaysOwner(start.path, 1, optionsRef.current.owners) !== 'owner' && dismiss.can()) {
393
+ mode = 'dismiss';
394
+ pick('dismiss', 1);
395
+ } else {
396
+ mode = 'native';
397
+ return;
398
+ }
399
+ } else {
400
+ if (textSelected() || sidewaysOwner(start.path, swipeDirOf(mx), optionsRef.current.owners) || keeps?.(swipeDirOf(mx))) {
401
+ mode = 'native';
402
+ return;
403
+ }
404
+ mode = 'swipe';
405
+ pickedDir = swipeDirOf(mx);
406
+ pick('step', pickedDir);
407
+ }
408
+ measure();
409
+ }
410
+ if (event.cancelable) event.preventDefault();
411
+ const dt = Math.max(1, event.timeStamp - last.t);
412
+ vx = (touch.clientX - last.x) / dt;
413
+ vy = (touch.clientY - last.y) / dt;
414
+ last = { x: touch.clientX, y: touch.clientY, t: event.timeStamp };
415
+ if (mode === 'dismiss') {
416
+ rawY = Math.max(0, my);
417
+ // Reduced motion: nothing follows the finger; a release far enough still closes.
418
+ if (!prefersReducedMotion()) {
419
+ draw(rawY, null, '', 'y');
420
+ fade(Math.min(1, rawY / (height() * SWIPE_DISMISS_FADE_SHARE)));
421
+ }
422
+ return;
423
+ }
424
+ raw = mx;
425
+ const dir = swipeDirOf(mx);
426
+ if (dir !== pickedDir || !moved[0]!.isConnected) {
427
+ // Turned back past where the finger went down (what moves that way may be something else), or the item was
428
+ // drawn again under the finger (the step a touch landed, drawn after the swipe was decided).
429
+ pickedDir = dir;
430
+ pick('step', dir);
431
+ }
432
+ const can = optionsRef.current.canStep(dir);
433
+ const offset = can ? mx : mx * SWIPE_END_RESIST;
434
+ // Reduced motion: nothing follows the finger (the host's card still says where a release goes).
435
+ if (!prefersReducedMotion()) draw(offset, null);
436
+ progressStore.set(can ? { dir, progress: Math.min(1, Math.abs(mx) / width()), offset: prefersReducedMotion() ? 0 : offset, phase: 'drag' } : null);
437
+ };
438
+ const endDismiss = () => {
439
+ const { dismiss, wouldAsk } = optionsRef.current;
440
+ const h = height();
441
+ if (!dismiss || !dismissCommits(rawY, h, vy)) return springBack();
442
+ if (wouldAsk() || prefersReducedMotion()) {
443
+ // A typed comment (the question row) or reduced motion: no fall, the close (or its question) at once.
444
+ draw(0, null);
445
+ unsetTransition();
446
+ fade(0);
447
+ dismiss.onDismiss();
448
+ return;
449
+ }
450
+ // The image falls away the way the finger went, the backdrop gone with it, then the viewer closes.
451
+ draw(h, `transform ${SWIPE_SLIDE_MS}ms cubic-bezier(0.23, 1, 0.32, 1), opacity ${SWIPE_SLIDE_MS}ms ease-out`, '0', 'y');
452
+ fade(1);
453
+ later(SWIPE_SLIDE_MS, () => {
454
+ dismiss.onDismiss();
455
+ // The close may land a moment later (the route changes on the browser's Back): the item stays gone till
456
+ // then. Still here after that (the close was refused): back as it was.
457
+ later(SWIPE_HANDOFF_MAX_MS, () => {
458
+ draw(0, null);
459
+ unsetTransition();
460
+ fade(0);
461
+ });
462
+ });
463
+ };
464
+ const onEnd = () => {
465
+ if (mode !== 'swipe' && mode !== 'dismiss') {
466
+ start = null;
467
+ mode = 'idle';
468
+ return;
469
+ }
470
+ const was = mode;
471
+ start = null;
472
+ mode = 'idle';
473
+ if (was === 'dismiss') return endDismiss();
474
+ const dir = swipeDirOf(raw);
475
+ const w = width();
476
+ const commits = swipeCommits(raw, w, vx);
477
+ const { canStep, onStep, wouldAsk, peek } = optionsRef.current;
478
+ if (!commits) return springBack();
479
+ if (!canStep(dir) || wouldAsk() || prefersReducedMotion()) {
480
+ // An end (the note), a typed comment (the question row) or reduced motion: no slide, the step (or its answer) at once.
481
+ draw(0, null);
482
+ unsetTransition();
483
+ progressStore.set(null);
484
+ onStep(dir);
485
+ return;
486
+ }
487
+ if (peek?.(dir)) {
488
+ // The neighbour is drawn beside the item: both slide a full width and the gap, and the host's picture of the
489
+ // neighbour stays where it lands until the new item is shown (no fade in: it is the same picture).
490
+ const travel = dir > 0 ? -(w + SWIPE_PEEK_GAP) : w + SWIPE_PEEK_GAP;
491
+ draw(travel, `transform ${SWIPE_SLIDE_MS}ms cubic-bezier(0.23, 1, 0.32, 1)`);
492
+ progressStore.set({ dir, progress: 1, offset: travel, phase: 'slide', ms: SWIPE_SLIDE_MS });
493
+ slideThen(SWIPE_SLIDE_MS, () => {
494
+ progressStore.set({ dir, progress: 1, offset: travel, phase: 'handoff' });
495
+ onStep(dir);
496
+ draw(0, null);
497
+ unsetTransition();
498
+ if (handoffTimer.current) clearTimeout(handoffTimer.current);
499
+ handoffTimer.current = setTimeout(() => {
500
+ handoffTimer.current = null;
501
+ if (progressStore.get()?.phase === 'handoff') progressStore.set(null);
502
+ }, SWIPE_HANDOFF_MAX_MS);
503
+ });
504
+ return;
505
+ }
506
+ // The item leaves the way the finger went; the next one comes in from the other side.
507
+ draw(dir > 0 ? -w : w, `transform ${SWIPE_SLIDE_MS}ms cubic-bezier(0.23, 1, 0.32, 1)`);
508
+ slideThen(SWIPE_SLIDE_MS, (interrupted) => {
509
+ progressStore.set(null);
510
+ onStep(dir);
511
+ if (interrupted) {
512
+ // A finger is already down for the next swipe: the new item is simply there.
513
+ draw(0, null, '');
514
+ unsetTransition();
515
+ return;
516
+ }
517
+ draw(dir > 0 ? w * 0.25 : -w * 0.25, null, '0');
518
+ frame = requestAnimationFrame(() => {
519
+ frame = requestAnimationFrame(() => {
520
+ frame = 0;
521
+ draw(0, `transform ${SWIPE_SLIDE_MS + 40}ms cubic-bezier(0.23, 1, 0.32, 1), opacity ${SWIPE_SLIDE_MS}ms ease-out`, '');
522
+ clearTransitionAfter(SWIPE_SLIDE_MS + 40);
523
+ });
524
+ });
525
+ });
526
+ };
527
+ // The area's listeners are there for the whole time the swipe is on, and touchmove is not passive
528
+ // from the touch's first move (iOS treats a touch's moves as cancelable only for listeners present when
529
+ // it began). preventDefault runs only once a swipe is decided (`onMove`), so a vertical scroll stays native.
530
+ // A gesture is also followed on its first target: a touch keeps firing at the element it started on, and
531
+ // that element may be drawn again mid-gesture (Pierre's code view does), after which it is out of the page
532
+ // and nothing it gets bubbles to the item.
533
+ let following: EventTarget | null = null;
534
+ let seen: Event | null = null;
535
+ const follow = (target: EventTarget | null) => {
536
+ if (following) {
537
+ following.removeEventListener('touchmove', onMoveAny as EventListener);
538
+ following.removeEventListener('touchend', onEndAny as EventListener);
539
+ following.removeEventListener('touchcancel', onEndAny as EventListener);
540
+ }
541
+ following = target;
542
+ if (following) {
543
+ following.addEventListener('touchmove', onMoveAny as EventListener, { passive: false });
544
+ following.addEventListener('touchend', onEndAny as EventListener);
545
+ following.addEventListener('touchcancel', onEndAny as EventListener);
546
+ }
547
+ };
548
+ const onStartEl = (event: TouchEvent) => {
549
+ onStart(event);
550
+ const first = event.composedPath()[0];
551
+ follow(start !== null && first && first !== el ? first : null);
552
+ };
553
+ // The same event reaches the target and the item while the target is in the page: handled once.
554
+ const onMoveAny = (event: TouchEvent) => {
555
+ if (event === seen) return;
556
+ seen = event;
557
+ onMove(event);
558
+ };
559
+ const onEndAny = (event: TouchEvent) => {
560
+ if (event === seen) return;
561
+ seen = event;
562
+ // A second finger lifting while one stays down is not the end of the gesture.
563
+ if (event.type === 'touchend' && event.touches.length > 0) return;
564
+ onEnd();
565
+ follow(null);
566
+ };
567
+ el.addEventListener('touchstart', onStartEl, { passive: true });
568
+ el.addEventListener('touchmove', onMoveAny, { passive: false });
569
+ el.addEventListener('touchend', onEndAny);
570
+ el.addEventListener('touchcancel', onEndAny);
571
+ return () => {
572
+ el.removeEventListener('touchstart', onStartEl);
573
+ el.removeEventListener('touchmove', onMoveAny);
574
+ el.removeEventListener('touchend', onEndAny);
575
+ el.removeEventListener('touchcancel', onEndAny);
576
+ follow(null);
577
+ for (const timer of timers) clearTimeout(timer);
578
+ if (sliding) clearTimeout(sliding.timer);
579
+ sliding = null;
580
+ cancelAnimationFrame(frame);
581
+ reset(moved);
582
+ reset([el]);
583
+ optionsRef.current.dismiss?.onProgress?.(0);
584
+ // A step's picture of the new item (`handoff`) outlives the swipe: it goes with `settle` or its own timer.
585
+ if (progressStore.get()?.phase !== 'handoff') progressStore.set(null);
586
+ };
587
+ }, [area, enabled, progressStore]);
588
+
589
+ useEffect(
590
+ () => () => {
591
+ if (handoffTimer.current) clearTimeout(handoffTimer.current);
592
+ },
593
+ [],
594
+ );
595
+
596
+ return store;
597
+ }