Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 111 additions & 3 deletions src/components/Lightbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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<TouchIntent>('none')
const touchMovedRef = useRef(false)
const swipeHandledRef = useRef(false)
const closeTapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)

// 判断本次 mousedown → mouseup 是否发生了拖拽,用于区分点击和拖拽
const didDragRef = useRef(false)
const suppressNextClickRef = useRef(false)
const suppressClickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)

// 切换图片时重置缩放
useEffect(() => {
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 (
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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()
}
}
}

Expand All @@ -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
Expand Down