From 380ada8bafb252e7837b996e3cc9013a02268e43 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 15:17:44 +0000 Subject: [PATCH] Stop the project gallery seizing up on a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things could hold the gallery still with nothing left to let it go again, and all three are reachable with a finger. The worst is the hold that keeps the strip still under a resting pointer. A finger cannot rest on anything, and `pointerleave` after a touch is not something every browser sends — so on a phone the hold went on and nothing lifted it. It is now asked for only where hovering is a thing the device does, and a cancelled gesture lifts it as well, since a browser taking a pan over is the moment a leave is most likely to go missing. A tap also leaves the card it landed on holding the focus, and a press now says that whoever is pressing is not the keyboard visitor the focus hold was put there for. A drag whose pointer capture is lost some other way is let go of through `lostpointercapture`, which is the one event that always arrives. The wait before the drift resumes is back to 1200ms, and it is now measured from the strip moving rather than from wheel and touch events. Watching the position covers every way it can be moved, the momentum that outlives a finger among them, and needs nothing to tell it the visitor has finished: it reads the last movement, so it always runs out. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015aCGjKwtBpk8NdVKxQiCRA --- app/components/DraggableMarquee.vue | 95 +++++++++++++++++++++++------ 1 file changed, 75 insertions(+), 20 deletions(-) diff --git a/app/components/DraggableMarquee.vue b/app/components/DraggableMarquee.vue index 3f4e623..9ac83cc 100644 --- a/app/components/DraggableMarquee.vue +++ b/app/components/DraggableMarquee.vue @@ -30,7 +30,7 @@ const props = withDefaults(defineProps<{ const DRAG_THRESHOLD = 4 /** How long the drift waits out the visitor's own scrolling, in milliseconds. */ -const RESUME_DELAY = 2000 +const RESUME_DELAY = 1200 const viewport = ref(null) @@ -63,6 +63,8 @@ const dragging = ref(false) const focusWithin = ref(false) const onScreen = ref(true) const reducedMotion = ref(false) +/** Whether this is a device that can hover at all. See `drifting`. */ +const canHover = ref(false) /** When the visitor last scrolled; long ago, until they have. */ let scrolledAt = Number.NEGATIVE_INFINITY @@ -71,10 +73,23 @@ function drifting(now: number) { && !reducedMotion.value && !dragging.value && !focusWithin.value - && !(props.pauseOnHover && resting.value) + // A finger cannot rest on anything, so on a phone this hold is not merely + // useless but dangerous: it is the one thing here with no reliable event to + // lift it again. `pointerleave` after a touch is not sent by every browser, + // and a hold nothing lifts stops the strip for the rest of the visit. + && !(props.pauseOnHover && canHover.value && resting.value) && now - scrolledAt > RESUME_DELAY } +/** Where we last put the strip, to tell our own scrolling from the visitor's. */ +let placed = Number.NaN + +/** Move the strip, and remember having done it. */ +function place(el: HTMLElement, position: number) { + el.scrollLeft = position + placed = el.scrollLeft +} + /** * Return the scroll position to the second copy. Every copy holds the same * thing, so a shift of exactly one copy is invisible; it only buys back the @@ -89,8 +104,8 @@ function normalize() { // different, and the browser would scroll back to it. if (focusWithin.value) return - if (el.scrollLeft >= period * 2) el.scrollLeft -= period - else if (el.scrollLeft < period) el.scrollLeft += period + if (el.scrollLeft >= period * 2) place(el, el.scrollLeft - period) + else if (el.scrollLeft < period) place(el, el.scrollLeft + period) } /** @@ -143,6 +158,11 @@ let travelled = 0 let dragged = false function onPointerdown(event: PointerEvent) { + // Whoever is pressing the strip is not the keyboard visitor that hold was + // put there for, and on a touchscreen a press is the only warning that the + // focus a tap left on a card is no longer being read from. + focusWithin.value = false + if (event.pointerType === 'touch' || event.button !== 0) return pointer = event.pointerId @@ -185,6 +205,28 @@ function onPointerup(event: PointerEvent) { scrolledAt = performance.now() } +/** + * The browser has taken the gesture over, to pan with, most often. Whatever + * was being held is not being held now — and the `pointerleave` that would + * usually say so is not something every browser sends after a cancelled touch. + * + * A drag that loses its pointer capture some other way — the window going to + * the background, the button coming up somewhere the page cannot see — is + * `onPointerup`'s business too, and `lostpointercapture` is where it hears + * about it. Without that the drag would never be let go of. + */ +function onPointercancel(event: PointerEvent) { + resting.value = false + onPointerup(event) +} + +function onPointerenter(event: PointerEvent) { + // A finger is not resting on anything; it is on its way somewhere. + if (event.pointerType === 'touch') return + + resting.value = true +} + /** Swallow the click that ends a drag, so a drag over a link is not a visit. */ function onClick(event: MouseEvent) { // A drag that ends outside the strip leaves no click to swallow, so this can @@ -211,12 +253,22 @@ function onFocusin(event: FocusEvent) { && event.target.matches(':focus-visible') } +/** + * Watch for the strip moving under anything other than the drift and stand + * aside when it does. + * + * Asking the position rather than listening for wheels and touches catches + * every way the strip can be moved — a trackpad, a swipe, the momentum that + * outlives the finger, a card being scrolled into view — and, more to the + * point, needs no event to tell it the visitor has finished. It reads the last + * movement, so it always runs out. + */ function onScroll() { - normalize() -} + const el = viewport.value + if (!el) return -function onUserScroll() { - scrolledAt = performance.now() + if (!(Math.abs(el.scrollLeft - placed) <= 1)) scrolledAt = performance.now() + normalize() } /* The drift itself. */ @@ -234,7 +286,7 @@ function tick(now: number) { if (!el || !drifting(now)) return const target = el.scrollLeft + carry + props.speed * elapsed / 1000 - el.scrollLeft = target + place(el, target) // Carry whatever the browser refused of a fractional step into the next // frame. Chrome snaps the offset to whole device pixels, so without this a // step under half a pixel rounds up every frame and the strip runs away, or @@ -248,12 +300,16 @@ onMounted(() => { const el = viewport.value if (!el) return - const stillness = window.matchMedia('(prefers-reduced-motion: reduce)') - reducedMotion.value = stillness.matches - const onStillnessChange = (event: MediaQueryListEvent) => { - reducedMotion.value = event.matches - } - stillness.addEventListener('change', onStillnessChange) + const queries: [MediaQueryList, (matches: boolean) => void][] = [ + [window.matchMedia('(prefers-reduced-motion: reduce)'), m => (reducedMotion.value = m)], + [window.matchMedia('(hover: hover)'), m => (canHover.value = m)] + ] + const listeners = queries.map(([query, set]) => { + set(query.matches) + const listener = (event: MediaQueryListEvent) => set(event.matches) + query.addEventListener('change', listener) + return () => query.removeEventListener('change', listener) + }) // The strip's own box gives the width to fill; its first copy gives the // width to fill it with, and that one changes as images and fonts land. @@ -275,7 +331,7 @@ onMounted(() => { onBeforeUnmount(() => { cancelAnimationFrame(request) - stillness.removeEventListener('change', onStillnessChange) + for (const remove of listeners) remove() resize.disconnect() intersection.disconnect() }) @@ -289,13 +345,12 @@ onMounted(() => { @pointerdown="onPointerdown" @pointermove="onPointermove" @pointerup="onPointerup" - @pointercancel="onPointerup" + @pointercancel="onPointercancel" + @lostpointercapture="onPointerup" @click.capture="onClick" @dragstart.prevent @scroll.passive="onScroll" - @wheel.passive="onUserScroll" - @touchmove.passive="onUserScroll" - @pointerenter="resting = true" + @pointerenter="onPointerenter" @pointerleave="resting = false" @focusin="onFocusin" @focusout="focusWithin = false"