diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx index 7aa7ce41..54859c40 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -6,6 +6,10 @@ import { createMaskPreviewDataUrl } from '../lib/canvasImage' const MIN_SCALE = 1 const MAX_SCALE = 10 +const SWIPE_INTENT_THRESHOLD = 10 +const SWIPE_ACTION_THRESHOLD = 40 + +type TouchIntent = 'none' | 'horizontal-swipe' | 'vertical-move' | 'zoom-pan' | 'pinch' function clamp(v: number, min: number, max: number) { return Math.max(min, Math.min(max, v)) @@ -203,10 +207,16 @@ function LightboxInner({ src, imageId, maskPreviewSrc, onClose, showNav, current const tapRef = useRef({ time: 0, x: 0, y: 0 }) const hadMultiTouchRef = useRef(false) const touchStartedOnImageRef = useRef(false) + const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null) + const touchIntentRef = useRef('none') + const touchMovedRef = useRef(false) + const swipeHandledRef = useRef(false) + const closeTapTimerRef = useRef | null>(null) // 判断本次 mousedown → mouseup 是否发生了拖拽,用于区分点击和拖拽 const didDragRef = useRef(false) const suppressNextClickRef = useRef(false) + const suppressClickTimerRef = useRef | null>(null) // 切换图片时重置缩放 useEffect(() => { @@ -231,6 +241,29 @@ function LightboxInner({ src, imageId, maskPreviewSrc, onClose, showNav, current return { cx: rect.left + rect.width / 2, cy: rect.top + rect.height / 2 } }, []) + const cancelCloseTap = useCallback(() => { + if (closeTapTimerRef.current) { + clearTimeout(closeTapTimerRef.current) + closeTapTimerRef.current = null + } + }, []) + + const suppressNextClickBriefly = useCallback(() => { + suppressNextClickRef.current = true + if (suppressClickTimerRef.current) clearTimeout(suppressClickTimerRef.current) + suppressClickTimerRef.current = setTimeout(() => { + suppressNextClickRef.current = false + suppressClickTimerRef.current = null + }, 350) + }, []) + + const resetTouchGesture = useCallback(() => { + touchStartRef.current = null + touchIntentRef.current = 'none' + touchMovedRef.current = false + swipeHandledRef.current = false + }, []) + const apply = useCallback((s: number, tx: number, ty: number) => { const ns = clamp(s, MIN_SCALE, MAX_SCALE) scaleRef.current = ns @@ -349,7 +382,10 @@ function LightboxInner({ src, imageId, maskPreviewSrc, onClose, showNav, current const onTouchStart = (e: TouchEvent) => { if (e.touches.length === 2) { e.preventDefault() + cancelCloseTap() + resetTouchGesture() hadMultiTouchRef.current = true + touchIntentRef.current = 'pinch' tapRef.current = { time: 0, x: 0, y: 0 } const [a, b] = [e.touches[0], e.touches[1]] const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) @@ -369,6 +405,10 @@ function LightboxInner({ src, imageId, maskPreviewSrc, onClose, showNav, current const now = Date.now() const prev = tapRef.current touchStartedOnImageRef.current = e.target instanceof HTMLImageElement + touchStartRef.current = { x: t.clientX, y: t.clientY, time: now } + touchIntentRef.current = 'none' + touchMovedRef.current = false + swipeHandledRef.current = false // 双击检测 if ( @@ -377,6 +417,7 @@ function LightboxInner({ src, imageId, maskPreviewSrc, onClose, showNav, current Math.abs(t.clientY - prev.y) < 30 ) { e.preventDefault() + cancelCloseTap() if (scaleRef.current > 1) { apply(1, 0, 0) } else { @@ -386,12 +427,14 @@ function LightboxInner({ src, imageId, maskPreviewSrc, onClose, showNav, current apply(3, -mx * 2, -my * 2) } tapRef.current = { time: 0, x: 0, y: 0 } + resetTouchGesture() return } tapRef.current = { time: now, x: t.clientX, y: t.clientY } if (scaleRef.current > 1 && touchStartedOnImageRef.current) { e.preventDefault() + touchIntentRef.current = 'zoom-pan' dragRef.current = { active: true, startX: t.clientX, @@ -417,6 +460,23 @@ function LightboxInner({ src, imageId, maskPreviewSrc, onClose, showNav, current const t = e.touches[0] const d = dragRef.current apply(scaleRef.current, d.baseTx + t.clientX - d.startX, d.baseTy + t.clientY - d.startY) + } else if (scaleRef.current <= 1 && e.touches.length === 1 && touchStartRef.current) { + const t = e.touches[0] + const dx = t.clientX - touchStartRef.current.x + const dy = t.clientY - touchStartRef.current.y + const absX = Math.abs(dx) + const absY = Math.abs(dy) + + if (absX > SWIPE_INTENT_THRESHOLD || absY > SWIPE_INTENT_THRESHOLD) { + touchMovedRef.current = true + } + if (touchIntentRef.current === 'none' && (absX > SWIPE_INTENT_THRESHOLD || absY > SWIPE_INTENT_THRESHOLD)) { + touchIntentRef.current = absX > absY ? 'horizontal-swipe' : 'vertical-move' + if (touchIntentRef.current === 'horizontal-swipe') cancelCloseTap() + } + if (touchIntentRef.current === 'horizontal-swipe') { + e.preventDefault() + } } } @@ -427,31 +487,79 @@ function LightboxInner({ src, imageId, maskPreviewSrc, onClose, showNav, current if (hadMultiTouchRef.current) { hadMultiTouchRef.current = false tapRef.current = { time: 0, x: 0, y: 0 } + resetTouchGesture() return } + + const start = touchStartRef.current + const changed = e.changedTouches[0] + const dx = start && changed ? changed.clientX - start.x : 0 + const intent = touchIntentRef.current + const moved = touchMovedRef.current + + if (intent === 'horizontal-swipe' && scaleRef.current <= 1) { + cancelCloseTap() + suppressNextClickBriefly() + swipeHandledRef.current = Math.abs(dx) >= SWIPE_ACTION_THRESHOLD + tapRef.current = { time: 0, x: 0, y: 0 } + e.preventDefault() + if (swipeHandledRef.current) { + if (dx < 0 && showNav) onNext() + if (dx > 0 && showNav) onPrev() + } + resetTouchGesture() + return + } + + if (moved || intent === 'vertical-move' || intent === 'zoom-pan') { + suppressNextClickBriefly() + tapRef.current = { time: 0, x: 0, y: 0 } + resetTouchGesture() + return + } + // 单击关闭:未缩放时任意位置关闭;缩放时仅点击图片外关闭。 if (scaleRef.current <= 1 || !touchStartedOnImageRef.current) { const prev = tapRef.current if (prev.time > 0 && Date.now() - prev.time < 300) { - setTimeout(() => { - if (tapRef.current.time === prev.time) { + cancelCloseTap() + closeTapTimerRef.current = setTimeout(() => { + closeTapTimerRef.current = null + if (tapRef.current.time === prev.time && !swipeHandledRef.current && !touchMovedRef.current) { onClose() } }, 310) } } + resetTouchGesture() } } + const onTouchCancel = () => { + cancelCloseTap() + tapRef.current = { time: 0, x: 0, y: 0 } + hadMultiTouchRef.current = false + pinchRef.current.active = false + dragRef.current.active = false + resetTouchGesture() + } + el.addEventListener('touchstart', onTouchStart, { passive: false }) el.addEventListener('touchmove', onTouchMove, { passive: false }) el.addEventListener('touchend', onTouchEnd) + el.addEventListener('touchcancel', onTouchCancel) return () => { + cancelCloseTap() + if (suppressClickTimerRef.current) { + clearTimeout(suppressClickTimerRef.current) + suppressClickTimerRef.current = null + } el.removeEventListener('touchstart', onTouchStart) el.removeEventListener('touchmove', onTouchMove) el.removeEventListener('touchend', onTouchEnd) + el.removeEventListener('touchcancel', onTouchCancel) } - }, [apply, getCenter, onClose]) + }, [apply, cancelCloseTap, getCenter, onClose, onNext, onPrev, resetTouchGesture, showNav, suppressNextClickBriefly]) const s = scaleRef.current const tx = txRef.current