fix(web): review-round attachment refinements

Body-portal the lightbox and toast so transformed ancestors cannot trap
their fixed positioning (a lightbox opened from a chat message covered only
the chat column); make the toast pointer-transparent; observe the rail
element's own size instead of window resizes; consume vertical wheel ticks
exclusively via a non-passive listener with LINE/PAGE delta normalization;
keep the start position when the rail mounts over an existing draft; honor
prefers-reduced-motion for the toast, remove-control, and paging; retry
loads through the guarded load effect; note the deliberate promptError
re-announce; pin the intake toast in the assembled snapshot; sync the
superseded multimodal note and package docs.
This commit is contained in:
creatixchu
2026-08-11 17:45:11 +08:00
parent e611e825b1
commit 87e3c95027
22 changed files with 218 additions and 73 deletions

View File

@@ -81,6 +81,12 @@
}
}
@media (prefers-reduced-motion: reduce) {
.remove {
transition: none;
}
}
.arrow {
position: absolute;
top: 50%;

View File

@@ -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}>

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import css from './ImageLightbox.module.css'
/** Lightbox strings the owner resolves from its own locale namespace. */
@@ -12,7 +13,9 @@ export interface ImageLightboxLabels {
/**
* Document-level original-image preview opened by clicking a thumbnail.
* Closes on Escape, backdrop press, or the close control, and restores focus
* to the opener on unmount.
* to the opener on unmount. Rendered through a body portal: an opener inside
* a transformed or filtered ancestor would otherwise trap the fixed backdrop
* in that ancestor's box instead of covering the viewport.
*
* @param props.src - the original image URL.
* @param props.alt - the image's alt text.
@@ -42,7 +45,7 @@ export function ImageLightbox({ src, alt, labels, onClose }: {
}
}, [onClose])
return (
return createPortal(
<div
className={css.backdrop}
role="dialog"
@@ -52,6 +55,7 @@ export function ImageLightbox({ src, alt, labels, onClose }: {
>
<img className={css.image} src={src} alt={alt} />
<button ref={closeRef} type="button" className={css.close} aria-label={labels.close} onClick={onClose}>×</button>
</div>
</div>,
document.body,
)
}

View File

@@ -40,24 +40,23 @@ export function MessageImage({ attachment, load, labels }: {
const [src, setSrc] = useState<string | null>(null)
const [error, setError] = useState(false)
const [open, setOpen] = useState(false)
// Retry re-arms the one load effect below, so every attempt — first load or
// retry — runs under the same liveness guard and the same reset.
const [attempt, setAttempt] = useState(0)
const request = useCallback(() => { setAttempt(a => a + 1) }, [])
const close = useCallback(() => { setOpen(false) }, [])
const size = useMemo(() => {
const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height)
return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) }
}, [attachment.height, attachment.width])
const request = useCallback(() => {
setError(false)
setSrc(null)
void load(attachment).then(setSrc).catch(() => { setError(true) })
}, [attachment, load])
useEffect(() => {
let live = true
setError(false)
setSrc(null)
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
return () => { live = false }
}, [attachment, load])
}, [attachment, load, attempt])
const label = attachment.name ?? labels.image
if (error) return <button type="button" className={css.error} onClick={request}>{labels.loadFailed}</button>