fix: carousel image lightbox
This commit is contained in:
@@ -145,7 +145,7 @@ export function PostCard({
|
|||||||
? `View photo ${i + 1} of ${post.images.length}`
|
? `View photo ${i + 1} of ${post.images.length}`
|
||||||
: "View photo"
|
: "View photo"
|
||||||
}
|
}
|
||||||
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10 cursor-zoom-in transition hover:ring-foreground/30 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10 cursor-pointer transition hover:ring-foreground/30 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={img.url}
|
src={img.url}
|
||||||
|
|||||||
@@ -1,26 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PostImageLightbox — full-screen viewer for a post's attached photos.
|
* PostImageLightbox — full-screen, swipeable viewer for a post's photos.
|
||||||
*
|
*
|
||||||
* Opened by tapping any image on a PostCard. Controlled by the caller: `index`
|
* Opened by tapping any image on a PostCard or PostScrollCard. Controlled by
|
||||||
* is the photo being shown (null = closed), so the card owns the state and this
|
* the caller: `index` is the photo on screen (null = closed), so the card owns
|
||||||
* component stays a pure presentation layer.
|
* the state and this stays presentation-only.
|
||||||
*
|
*
|
||||||
* Dismiss: tap the backdrop ring around the photo, the ✕, or press Escape.
|
* Navigation is a native CSS scroll-snap strip — the same primitive the feed
|
||||||
* Navigate a multi-photo post with ← / → or the on-screen chevrons.
|
* carousel uses — so swiping works on touch for free, and the ← / → keys and
|
||||||
|
* chevrons simply scroll that strip. The scroller is the single source of
|
||||||
|
* truth: buttons/keys never set `index` directly, they scroll, and the scroll
|
||||||
|
* handler reports the settled index back up. That one-way flow is what keeps a
|
||||||
|
* programmatic scroll from fighting the user's own gesture.
|
||||||
|
*
|
||||||
|
* Dismiss: tap the photo or the surrounding ring, the ✕, or press Escape. Taps
|
||||||
|
* are distinguished from swipes by useTapGuard, so navigating never closes.
|
||||||
*
|
*
|
||||||
* Rendered through a portal onto <body> so it escapes the feed's virtualised,
|
* Rendered through a portal onto <body> so it escapes the feed's virtualised,
|
||||||
* transformed rows — an overlay nested inside one of those would be clipped by
|
* transformed rows — an overlay nested inside one would be positioned against
|
||||||
* the row's stacking context and positioned against the transform, not the
|
* that transform and clipped by its stacking context, not the viewport.
|
||||||
* viewport.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||||
import type { PostImage } from "@/lib/social";
|
import type { PostImage } from "@/lib/social";
|
||||||
|
import { useTapGuard } from "./use-tap-guard";
|
||||||
|
|
||||||
export function PostImageLightbox({
|
export function PostImageLightbox({
|
||||||
images,
|
images,
|
||||||
@@ -41,17 +48,40 @@ export function PostImageLightbox({
|
|||||||
}) {
|
}) {
|
||||||
const open = index !== null;
|
const open = index !== null;
|
||||||
const count = images.length;
|
const count = images.length;
|
||||||
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { onPointerDown, wasTap } = useTapGuard(scrollerRef);
|
||||||
|
|
||||||
|
/** Scroll the strip to a slide. Clamped, not wrapped: the buttons and keys
|
||||||
|
* should be able to reach exactly what a swipe can, and you can't swipe
|
||||||
|
* past the end. */
|
||||||
|
const scrollToIndex = useCallback(
|
||||||
|
(i: number, behavior: ScrollBehavior) => {
|
||||||
|
const el = scrollerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const target = Math.max(0, Math.min(i, count - 1));
|
||||||
|
el.scrollTo({ left: target * el.offsetWidth, behavior });
|
||||||
|
},
|
||||||
|
[count],
|
||||||
|
);
|
||||||
|
|
||||||
// Wrap around at both ends so a long press on → never dead-ends.
|
|
||||||
const goPrev = useCallback(() => {
|
const goPrev = useCallback(() => {
|
||||||
if (index === null || count < 2) return;
|
if (index === null) return;
|
||||||
onIndexChange((index - 1 + count) % count);
|
scrollToIndex(index - 1, "smooth");
|
||||||
}, [index, count, onIndexChange]);
|
}, [index, scrollToIndex]);
|
||||||
|
|
||||||
const goNext = useCallback(() => {
|
const goNext = useCallback(() => {
|
||||||
if (index === null || count < 2) return;
|
if (index === null) return;
|
||||||
onIndexChange((index + 1) % count);
|
scrollToIndex(index + 1, "smooth");
|
||||||
}, [index, count, onIndexChange]);
|
}, [index, scrollToIndex]);
|
||||||
|
|
||||||
|
// Jump to the tapped photo when the viewer opens. Deliberately keyed on
|
||||||
|
// `open` only — re-running on every `index` change would yank the strip back
|
||||||
|
// mid-swipe, since scrolling is what updates `index` in the first place.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
scrollToIndex(index ?? 0, "auto");
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -74,95 +104,126 @@ export function PostImageLightbox({
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
function onScroll(e: React.UIEvent<HTMLDivElement>) {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const w = el.offsetWidth;
|
||||||
|
if (w === 0) return;
|
||||||
|
const i = Math.round(el.scrollLeft / w);
|
||||||
|
if (i !== index && i >= 0 && i < count) onIndexChange(i);
|
||||||
|
}
|
||||||
|
|
||||||
if (!open || typeof document === "undefined") return null;
|
if (!open || typeof document === "undefined") return null;
|
||||||
const img = images[index];
|
if (!images[index]) return null;
|
||||||
if (!img) return null;
|
|
||||||
|
|
||||||
return createPortal(
|
return (
|
||||||
<div
|
createPortal(
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={
|
|
||||||
count > 1 ? `Photo ${index + 1} of ${count}` : "Photo"
|
|
||||||
}
|
|
||||||
onClick={onClose}
|
|
||||||
className="fixed inset-0 z-100 bg-black/90 backdrop-blur-sm animate-in fade-in-0"
|
|
||||||
>
|
|
||||||
{/* The photo sits inset from the edges, leaving a genuine backdrop ring
|
|
||||||
that closes on tap. `fill` resolves against this box (padding would
|
|
||||||
NOT inset an absolutely-positioned child), hence inset-* rather than
|
|
||||||
p-*. stopPropagation keeps a tap on the photo itself from closing. */}
|
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
role="dialog"
|
||||||
className="absolute inset-4 sm:inset-12"
|
aria-modal="true"
|
||||||
>
|
aria-label={count > 1 ? `Photo ${index + 1} of ${count}` : "Photo"}
|
||||||
<Image
|
|
||||||
// Keyed so switching photos swaps the element instead of
|
|
||||||
// cross-fading a stale bitmap into the new slot.
|
|
||||||
key={img.id}
|
|
||||||
src={img.url}
|
|
||||||
alt=""
|
|
||||||
fill
|
|
||||||
sizes="100vw"
|
|
||||||
className="object-contain"
|
|
||||||
unoptimized
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close"
|
className="fixed inset-0 z-100 bg-black/90 backdrop-blur-sm animate-in fade-in-0"
|
||||||
className="fixed top-4 right-4 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
|
||||||
>
|
>
|
||||||
<X className="size-5" />
|
{/* The strip sits inset from the edges, leaving a backdrop ring that
|
||||||
</button>
|
also closes. `absolute inset-*` rather than padding because padding
|
||||||
|
does NOT inset an absolutely-positioned `fill` child. */}
|
||||||
{count > 1 ? (
|
<div
|
||||||
<>
|
ref={scrollerRef}
|
||||||
<button
|
onScroll={onScroll}
|
||||||
type="button"
|
onPointerDown={onPointerDown}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
// Swiping between photos must never dismiss the viewer; only a
|
||||||
goPrev();
|
// genuine tap falls through to the backdrop's onClose.
|
||||||
}}
|
if (!wasTap(e)) e.stopPropagation();
|
||||||
aria-label="Previous photo"
|
}}
|
||||||
className="fixed left-2 sm:left-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
className="absolute inset-4 sm:inset-12 flex overflow-x-auto snap-x snap-mandatory overscroll-contain [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-5" />
|
{images.map((img) => (
|
||||||
</button>
|
<div
|
||||||
<button
|
key={img.id}
|
||||||
type="button"
|
className="relative h-full w-full shrink-0 snap-center"
|
||||||
onClick={(e) => {
|
>
|
||||||
e.stopPropagation();
|
<Image
|
||||||
goNext();
|
src={img.url}
|
||||||
}}
|
alt=""
|
||||||
aria-label="Next photo"
|
fill
|
||||||
className="fixed right-2 sm:right-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
sizes="100vw"
|
||||||
>
|
className="object-contain"
|
||||||
<ChevronRight className="size-5" />
|
unoptimized
|
||||||
</button>
|
/>
|
||||||
{/* Counter sits top-left so it never collides with the caption
|
</div>
|
||||||
scrim along the bottom. */}
|
))}
|
||||||
<span className="fixed top-5 left-4 rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white tabular-nums">
|
|
||||||
{index + 1} / {count}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Caption scrim. `pointer-events-none` so it never intercepts a tap
|
|
||||||
meant for the backdrop — the whole strip stays click-to-close — and
|
|
||||||
the gradient fades upward so the photo's lower edge stays readable
|
|
||||||
rather than being cut by a hard band. */}
|
|
||||||
{caption?.trim() ? (
|
|
||||||
<div className="pointer-events-none fixed inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/60 to-transparent px-4 pt-16 pb-6">
|
|
||||||
<p className="mx-auto max-w-2xl text-center font-(family-name:--font-title) text-sm sm:text-base leading-relaxed text-white/95 line-clamp-3">
|
|
||||||
“{caption.trim()}”
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>,
|
<button
|
||||||
document.body,
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="fixed top-4 right-4 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="size-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{count > 1 ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goPrev();
|
||||||
|
}}
|
||||||
|
aria-label="Previous photo"
|
||||||
|
className="fixed left-2 sm:left-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-0 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={index === count - 1}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goNext();
|
||||||
|
}}
|
||||||
|
aria-label="Next photo"
|
||||||
|
className="fixed right-2 sm:right-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-0 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Counter sits top-left so it never collides with the caption
|
||||||
|
scrim along the bottom. */}
|
||||||
|
<span className="fixed top-5 left-4 rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white tabular-nums">
|
||||||
|
{index + 1} / {count}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Dot indicators, mirroring the in-feed carousel. */}
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 bottom-1.5 flex items-center justify-center gap-1.5">
|
||||||
|
{images.map((img, i) => (
|
||||||
|
<span
|
||||||
|
key={img.id}
|
||||||
|
aria-hidden
|
||||||
|
className={`size-1.5 rounded-full transition-all ${
|
||||||
|
i === index ? "bg-white" : "bg-white/40"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Caption scrim. `pointer-events-none` so it never intercepts a tap
|
||||||
|
meant for the backdrop, and the gradient fades upward so the
|
||||||
|
photo's lower edge stays readable rather than cut by a hard band. */}
|
||||||
|
{caption?.trim() ? (
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/60 to-transparent px-4 pt-16 pb-8">
|
||||||
|
<p className="mx-auto max-w-2xl text-center font-(family-name:--font-title) text-sm sm:text-base leading-relaxed text-white/95 line-clamp-3">
|
||||||
|
“{caption.trim()}”
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
|
|||||||
import { PostActionsFooter } from "./post-actions-footer";
|
import { PostActionsFooter } from "./post-actions-footer";
|
||||||
import { PostCardMenu } from "./post-card-menu";
|
import { PostCardMenu } from "./post-card-menu";
|
||||||
import { PostImageLightbox } from "./post-image-lightbox";
|
import { PostImageLightbox } from "./post-image-lightbox";
|
||||||
|
import { useTapGuard } from "./use-tap-guard";
|
||||||
import { FeedFollowButton } from "./feed-follow-button";
|
import { FeedFollowButton } from "./feed-follow-button";
|
||||||
import { UpdateBackgroundModal } from "../update-background-modal";
|
import { UpdateBackgroundModal } from "../update-background-modal";
|
||||||
import { MentionsDrawer } from "../mentions-drawer";
|
import { MentionsDrawer } from "../mentions-drawer";
|
||||||
@@ -252,12 +253,9 @@ function ImageCarousel({
|
|||||||
}) {
|
}) {
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||||
// Where the pointer went down, plus the scroller's position at that moment.
|
// Swiping the carousel and tapping a photo have to stay independent — the
|
||||||
// Swiping the carousel and tapping a photo have to stay independent: the
|
// strip scrolls natively, and browsers still fire a click after some swipes.
|
||||||
// carousel scrolls natively (CSS scroll-snap), and browsers still fire a
|
const { onPointerDown, wasTap } = useTapGuard(scrollerRef);
|
||||||
// click after some swipe gestures. Comparing both the pointer travel AND
|
|
||||||
// whether the strip actually scrolled lets us drop those.
|
|
||||||
const tapStart = useRef({ x: 0, y: 0, scrollLeft: 0 });
|
|
||||||
|
|
||||||
function onScroll(e: React.UIEvent<HTMLDivElement>) {
|
function onScroll(e: React.UIEvent<HTMLDivElement>) {
|
||||||
const el = e.currentTarget;
|
const el = e.currentTarget;
|
||||||
@@ -266,26 +264,6 @@ function ImageCarousel({
|
|||||||
setActive(Math.round(el.scrollLeft / w));
|
setActive(Math.round(el.scrollLeft / w));
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerDown(e: React.PointerEvent) {
|
|
||||||
tapStart.current = {
|
|
||||||
x: e.clientX,
|
|
||||||
y: e.clientY,
|
|
||||||
scrollLeft: scrollerRef.current?.scrollLeft ?? 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True only for a genuine tap — barely any pointer travel and no scroll. */
|
|
||||||
function wasTap(e: React.MouseEvent) {
|
|
||||||
const moved =
|
|
||||||
Math.abs(e.clientX - tapStart.current.x) > 8 ||
|
|
||||||
Math.abs(e.clientY - tapStart.current.y) > 8;
|
|
||||||
const scrolled =
|
|
||||||
Math.abs(
|
|
||||||
(scrollerRef.current?.scrollLeft ?? 0) - tapStart.current.scrollLeft,
|
|
||||||
) > 4;
|
|
||||||
return !moved && !scrolled;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (images.length === 1) {
|
if (images.length === 1) {
|
||||||
// Single image: no scroller, so every click is unambiguously a tap.
|
// Single image: no scroller, so every click is unambiguously a tap.
|
||||||
return (
|
return (
|
||||||
@@ -293,7 +271,7 @@ function ImageCarousel({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpen(0)}
|
onClick={() => onOpen(0)}
|
||||||
aria-label="View photo"
|
aria-label="View photo"
|
||||||
className="relative block w-full aspect-square bg-muted cursor-zoom-in outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
className="relative block w-full aspect-square bg-muted cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={images[0].url}
|
src={images[0].url}
|
||||||
@@ -328,7 +306,7 @@ function ImageCarousel({
|
|||||||
}}
|
}}
|
||||||
aria-roledescription="slide"
|
aria-roledescription="slide"
|
||||||
aria-label={`View photo ${i + 1} of ${images.length}`}
|
aria-label={`View photo ${i + 1} of ${images.length}`}
|
||||||
className="relative block w-full shrink-0 snap-start aspect-square bg-muted cursor-zoom-in outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
className="relative block w-full shrink-0 snap-start aspect-square bg-muted cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={img.url}
|
src={img.url}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useTapGuard — tells a genuine tap apart from a swipe on a natively-scrolling
|
||||||
|
* strip (CSS scroll-snap carousels).
|
||||||
|
*
|
||||||
|
* Browsers still fire a `click` after some swipe gestures, so a plain onClick
|
||||||
|
* on a slide would trigger mid-swipe. Snapshot the pointer position AND the
|
||||||
|
* scroller's offset on pointerdown, then check both when the click arrives:
|
||||||
|
*
|
||||||
|
* • pointer travel — catches a drag that didn't scroll (e.g. hit the end)
|
||||||
|
* • scroll movement — catches an inertial swipe, where the pointer barely
|
||||||
|
* moves before `click` fires but the strip is already gliding
|
||||||
|
*
|
||||||
|
* Either signal alone misses cases, hence both. No staleness risk: a click is
|
||||||
|
* always immediately preceded by a pointerdown on the same element.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useRef, type RefObject } from "react";
|
||||||
|
|
||||||
|
/** Pointer travel (px) still considered a tap rather than a drag. */
|
||||||
|
const MOVE_TOLERANCE = 8;
|
||||||
|
/** Scroll movement (px) still considered "didn't scroll". */
|
||||||
|
const SCROLL_TOLERANCE = 4;
|
||||||
|
|
||||||
|
type PointerLike = { clientX: number; clientY: number };
|
||||||
|
|
||||||
|
export function useTapGuard(scrollerRef: RefObject<HTMLElement | null>) {
|
||||||
|
const start = useRef({ x: 0, y: 0, scrollLeft: 0 });
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerLike) {
|
||||||
|
start.current = {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
scrollLeft: scrollerRef.current?.scrollLeft ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wasTap(e: PointerLike) {
|
||||||
|
const moved =
|
||||||
|
Math.abs(e.clientX - start.current.x) > MOVE_TOLERANCE ||
|
||||||
|
Math.abs(e.clientY - start.current.y) > MOVE_TOLERANCE;
|
||||||
|
const scrolled =
|
||||||
|
Math.abs(
|
||||||
|
(scrollerRef.current?.scrollLeft ?? 0) - start.current.scrollLeft,
|
||||||
|
) > SCROLL_TOLERANCE;
|
||||||
|
return !moved && !scrolled;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { onPointerDown, wasTap };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user