|
|
@@ -2,7 +2,6 @@
|
|
|
* by edge arrows, hover-revealed per-item remove, single-click open. */
|
|
|
|
|
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
|
-import type { WheelEvent } from 'react'
|
|
|
import clsx from 'clsx'
|
|
|
import {
|
|
|
IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseFill14,
|
|
|
@@ -33,15 +32,31 @@ export interface AttachmentRailLabels {
|
|
|
scrollRight: string
|
|
|
}
|
|
|
|
|
|
+/** Approximate pixels per wheel step for `deltaMode` LINE deltas (Firefox
|
|
|
+ * notch wheels report lines, not pixels). */
|
|
|
+const WHEEL_LINE_PX = 16
|
|
|
+
|
|
|
+/** Smooth paging unless the user asked for reduced motion. */
|
|
|
+function pageBehavior(): ScrollBehavior {
|
|
|
+ // jsdom (the unit lane) implements no matchMedia despite lib.dom's
|
|
|
+ // non-optional typing; the optional call keeps that lane on the default.
|
|
|
+ // oxlint-disable-next-line typescript/no-unnecessary-condition
|
|
|
+ return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Horizontal thumbnail rail over the caller's draft attachments.
|
|
|
*
|
|
|
* The rail scrolls with its scrollbar hidden; overflow is announced by edge
|
|
|
* arrows recomputed from scroll geometry on scroll, item-count changes, and
|
|
|
- * window resizes. A vertical wheel pans horizontally, a newly added item is
|
|
|
- * revealed at the rail's end, and each thumbnail opens on a single click while
|
|
|
- * its remove control sits inside the card and reveals on hover or focus.
|
|
|
- * The owner decides mounting; it renders the rail only while items exist.
|
|
|
+ * rail size changes (a ResizeObserver on the rail element, so sidebar or
|
|
|
+ * panel resizes count, not only window resizes). A vertical wheel pans the
|
|
|
+ * rail horizontally and is consumed exclusively (non-passive listener), a
|
|
|
+ * newly added item is revealed at the rail's end while a rail that mounts
|
|
|
+ * over an existing draft keeps its start position, and each thumbnail opens
|
|
|
+ * on a single click while its remove control sits inside the card and
|
|
|
+ * reveals on hover or focus. The owner decides mounting; it renders the rail
|
|
|
+ * only while items exist.
|
|
|
*
|
|
|
* @param props.items - resolved thumbnails in draft order.
|
|
|
* @param props.labels - rail-level strings (group name, open tooltip, arrows).
|
|
|
@@ -56,7 +71,10 @@ export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, on
|
|
|
onRemove: (item: T) => void
|
|
|
}) {
|
|
|
const railRef = useRef<HTMLDivElement | null>(null)
|
|
|
- const countRef = useRef(0)
|
|
|
+ // null marks the first layout pass: a rail that MOUNTS over an existing
|
|
|
+ // draft (session switch back to held images) is initial display, not
|
|
|
+ // growth, and must not jump to the end.
|
|
|
+ const countRef = useRef<number | null>(null)
|
|
|
const [edges, setEdges] = useState({ left: false, right: false })
|
|
|
const updateEdges = useCallback(() => {
|
|
|
const el = railRef.current
|
|
|
@@ -68,16 +86,51 @@ export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, on
|
|
|
setEdges(prev => prev.left === left && prev.right === right ? prev : { left, right })
|
|
|
}, [])
|
|
|
useLayoutEffect(() => {
|
|
|
- const grew = items.length > countRef.current
|
|
|
+ const grew = countRef.current !== null && items.length > countRef.current
|
|
|
countRef.current = items.length
|
|
|
const el = railRef.current
|
|
|
+ /* v8 ignore next -- defensive: the rail div renders unconditionally, so the layout effect always finds it. */
|
|
|
+ if (el === null) return
|
|
|
// A newly added attachment lands at the rail's end: reveal it.
|
|
|
- if (grew && el !== null) el.scrollLeft = el.scrollWidth - el.clientWidth
|
|
|
+ if (grew) el.scrollLeft = el.scrollWidth - el.clientWidth
|
|
|
updateEdges()
|
|
|
}, [items.length, updateEdges])
|
|
|
useEffect(() => {
|
|
|
- window.addEventListener('resize', updateEdges)
|
|
|
- return () => { window.removeEventListener('resize', updateEdges) }
|
|
|
+ const el = railRef.current
|
|
|
+ /* v8 ignore next -- defensive: the rail div renders unconditionally, so the mount effect always finds it. */
|
|
|
+ if (el === null) return
|
|
|
+ // The rail's width follows the composer, which resizes with sidebars and
|
|
|
+ // panels, not only the window — observe the element itself. jsdom (the
|
|
|
+ // unit lane) implements no ResizeObserver; every browser gets the
|
|
|
+ // subscription.
|
|
|
+ let disconnect = (): void => {}
|
|
|
+ if (typeof ResizeObserver !== 'undefined') {
|
|
|
+ const observer = new ResizeObserver(updateEdges)
|
|
|
+ observer.observe(el)
|
|
|
+ disconnect = () => { observer.disconnect() }
|
|
|
+ }
|
|
|
+ // A vertical wheel pans the rail horizontally and is consumed: without
|
|
|
+ // preventDefault the same tick would also scroll the conversation behind
|
|
|
+ // the composer. React's root wheel listener is passive, so the exclusive
|
|
|
+ // conversion needs this manually attached non-passive listener. LINE and
|
|
|
+ // PAGE deltas (Firefox notch wheels) are normalized to pixels before the
|
|
|
+ // per-tick clamp that keeps a fast wheel followable.
|
|
|
+ const onWheel = (event: globalThis.WheelEvent): void => {
|
|
|
+ if (event.deltaX !== 0 || event.deltaY === 0) return
|
|
|
+ const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
|
|
+ ? WHEEL_LINE_PX
|
|
|
+ : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? el.clientWidth : 1
|
|
|
+ event.preventDefault()
|
|
|
+ el.scrollBy({
|
|
|
+ left: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60),
|
|
|
+ behavior: 'auto',
|
|
|
+ })
|
|
|
+ }
|
|
|
+ el.addEventListener('wheel', onWheel, { passive: false })
|
|
|
+ return () => {
|
|
|
+ disconnect()
|
|
|
+ el.removeEventListener('wheel', onWheel)
|
|
|
+ }
|
|
|
}, [updateEdges])
|
|
|
const page = (direction: -1 | 1): void => {
|
|
|
const el = railRef.current
|
|
|
@@ -85,16 +138,7 @@ export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, on
|
|
|
if (el === null) return
|
|
|
// One viewport minus a card keeps the last visible thumbnail as context;
|
|
|
// the floor keeps narrow rails paging a useful distance.
|
|
|
- el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: 'smooth' })
|
|
|
- }
|
|
|
- // A vertical wheel pans the rail horizontally (trackpads pan natively via
|
|
|
- // deltaX); per-tick travel is clamped so a fast notch wheel stays followable.
|
|
|
- const onWheel = (event: WheelEvent<HTMLDivElement>): void => {
|
|
|
- if (event.deltaX !== 0 || event.deltaY === 0) return
|
|
|
- event.currentTarget.scrollBy({
|
|
|
- left: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY), 60),
|
|
|
- behavior: 'auto',
|
|
|
- })
|
|
|
+ el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: pageBehavior() })
|
|
|
}
|
|
|
return (
|
|
|
<div className={css.root}>
|
|
|
@@ -114,7 +158,6 @@ export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, on
|
|
|
role="group"
|
|
|
aria-label={labels.group}
|
|
|
onScroll={updateEdges}
|
|
|
- onWheel={onWheel}
|
|
|
>
|
|
|
{items.map(item => (
|
|
|
<div key={item.id} className={css.item}>
|