808 lines
23 KiB
TypeScript
808 lines
23 KiB
TypeScript
// frontend\src\components\ui\SwipeCard.tsx
|
|
|
|
'use client'
|
|
|
|
import { useEffect, useRef, useState, useCallback, type ReactNode, forwardRef, useImperativeHandle } from 'react'
|
|
import { FireIcon as FireSolidIcon } from '@heroicons/react/24/solid'
|
|
import { createRoot } from 'react-dom/client'
|
|
|
|
function cn(...parts: Array<string | false | null | undefined>) {
|
|
return parts.filter(Boolean).join(' ')
|
|
}
|
|
|
|
function getGlobalFxLayer(): HTMLDivElement | null {
|
|
if (typeof document === 'undefined') return null
|
|
|
|
const ID = '__swipecard_hot_fx_layer__'
|
|
let el = document.getElementById(ID) as HTMLDivElement | null
|
|
|
|
if (!el) {
|
|
el = document.createElement('div')
|
|
el.id = ID
|
|
el.style.position = 'fixed'
|
|
el.style.inset = '0'
|
|
el.style.pointerEvents = 'none'
|
|
el.style.zIndex = '2147483647'
|
|
;(el.style as any).contain = 'layout style paint'
|
|
document.body.appendChild(el)
|
|
}
|
|
|
|
return el
|
|
}
|
|
|
|
export type SwipeAction = {
|
|
label: ReactNode
|
|
className?: string
|
|
}
|
|
|
|
export type SwipeCardProps = {
|
|
children: ReactNode
|
|
enabled?: boolean
|
|
disabled?: boolean
|
|
onTap?: () => void
|
|
onSwipeLeft: () => boolean | void | Promise<boolean | void>
|
|
onSwipeRight: () => boolean | void | Promise<boolean | void>
|
|
className?: string
|
|
leftAction?: SwipeAction
|
|
rightAction?: SwipeAction
|
|
thresholdPx?: number
|
|
thresholdRatio?: number
|
|
snapMs?: number
|
|
commitMs?: number
|
|
ignoreFromBottomPx?: number
|
|
ignoreSelector?: string
|
|
tapIgnoreSelector?: string
|
|
onDoubleTap?: () => boolean | void | Promise<boolean | void>
|
|
hotTargetSelector?: string
|
|
doubleTapMs?: number
|
|
doubleTapMaxMovePx?: number
|
|
pressDelayMs?: number
|
|
enablePressHold?: boolean
|
|
onPressStart?: (info: { pointerType: string; button: number }) => void
|
|
onPressEnd?: () => void
|
|
}
|
|
|
|
export type SwipeCardHandle = {
|
|
swipeLeft: (opts?: { runAction?: boolean }) => Promise<boolean>
|
|
swipeRight: (opts?: { runAction?: boolean }) => Promise<boolean>
|
|
reset: () => void
|
|
}
|
|
|
|
const HORIZONTAL_START_PX = 10
|
|
const LOCK_RATIO = 1.15
|
|
const FLICK_VELOCITY = 0.55
|
|
const MAX_OVERSWIPE_PX = 64
|
|
const ROTATE_MAX_DEG = 5
|
|
const SCALE_MIN = 0.992
|
|
|
|
function clamp(v: number, min: number, max: number) {
|
|
return Math.max(min, Math.min(max, v))
|
|
}
|
|
|
|
function rubberBand(delta: number, width: number) {
|
|
if (delta === 0) return 0
|
|
|
|
const sign = Math.sign(delta)
|
|
const abs = Math.abs(delta)
|
|
const limit = width * 0.9
|
|
|
|
if (abs <= limit) return delta
|
|
|
|
const overflow = abs - limit
|
|
const resisted = limit + overflow * 0.18
|
|
return sign * Math.min(resisted, width + MAX_OVERSWIPE_PX)
|
|
}
|
|
|
|
const SwipeCard = forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard(
|
|
{
|
|
children,
|
|
enabled = true,
|
|
disabled = false,
|
|
onTap,
|
|
onSwipeLeft,
|
|
onSwipeRight,
|
|
className,
|
|
thresholdPx = 140,
|
|
thresholdRatio = 0.28,
|
|
ignoreFromBottomPx = 72,
|
|
ignoreSelector = '[data-swipe-ignore]',
|
|
snapMs = 220,
|
|
commitMs = 220,
|
|
tapIgnoreSelector = 'button,a,input,textarea,select,video[controls],video[controls] *,[data-tap-ignore]',
|
|
onDoubleTap,
|
|
hotTargetSelector = '[data-hot-target]',
|
|
doubleTapMs = 360,
|
|
doubleTapMaxMovePx = 48,
|
|
pressDelayMs = 220,
|
|
enablePressHold = true,
|
|
onPressStart,
|
|
onPressEnd,
|
|
},
|
|
ref
|
|
) {
|
|
const cardRef = useRef<HTMLDivElement | null>(null)
|
|
const outerRef = useRef<HTMLDivElement | null>(null)
|
|
|
|
const doubleTapBusyRef = useRef(false)
|
|
const pressTimerRef = useRef<number | null>(null)
|
|
const longPressActiveRef = useRef(false)
|
|
|
|
const rafRef = useRef<number | null>(null)
|
|
const dxRef = useRef(0)
|
|
const thresholdRef = useRef(0)
|
|
const widthRef = useRef(360)
|
|
|
|
const tapTimerRef = useRef<number | null>(null)
|
|
const lastTapRef = useRef<{ t: number; x: number; y: number } | null>(null)
|
|
|
|
const velocityXRef = useRef(0)
|
|
const lastMoveXRef = useRef(0)
|
|
const lastMoveTRef = useRef(0)
|
|
|
|
const pointer = useRef<{
|
|
id: number | null
|
|
startX: number
|
|
startY: number
|
|
dragging: boolean
|
|
captured: boolean
|
|
tapIgnored: boolean
|
|
noSwipe: boolean
|
|
axisLocked: 'x' | 'y' | null
|
|
}>({
|
|
id: null,
|
|
startX: 0,
|
|
startY: 0,
|
|
dragging: false,
|
|
captured: false,
|
|
tapIgnored: false,
|
|
noSwipe: false,
|
|
axisLocked: null,
|
|
})
|
|
|
|
const [dx, setDx] = useState(0)
|
|
const [armedDir, setArmedDir] = useState<null | 'left' | 'right'>(null)
|
|
const [animMs, setAnimMs] = useState(0)
|
|
|
|
const clearTapTimer = useCallback(() => {
|
|
if (tapTimerRef.current != null) {
|
|
window.clearTimeout(tapTimerRef.current)
|
|
tapTimerRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
const clearPressTimer = useCallback(() => {
|
|
if (pressTimerRef.current != null) {
|
|
window.clearTimeout(pressTimerRef.current)
|
|
pressTimerRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
const endLongPress = useCallback(() => {
|
|
clearPressTimer()
|
|
|
|
if (!longPressActiveRef.current) return
|
|
|
|
longPressActiveRef.current = false
|
|
onPressEnd?.()
|
|
}, [clearPressTimer, onPressEnd])
|
|
|
|
const flushDx = useCallback((nextDx: number) => {
|
|
dxRef.current = nextDx
|
|
if (rafRef.current != null) return
|
|
rafRef.current = requestAnimationFrame(() => {
|
|
rafRef.current = null
|
|
setDx(dxRef.current)
|
|
})
|
|
}, [])
|
|
|
|
const reset = useCallback(() => {
|
|
clearPressTimer()
|
|
longPressActiveRef.current = false
|
|
|
|
if (rafRef.current != null) {
|
|
cancelAnimationFrame(rafRef.current)
|
|
rafRef.current = null
|
|
}
|
|
|
|
dxRef.current = 0
|
|
velocityXRef.current = 0
|
|
|
|
if (cardRef.current) {
|
|
cardRef.current.style.touchAction = 'pan-y'
|
|
}
|
|
|
|
setAnimMs(snapMs)
|
|
setDx(0)
|
|
setArmedDir(null)
|
|
|
|
window.setTimeout(() => {
|
|
setAnimMs(0)
|
|
}, snapMs)
|
|
}, [snapMs, clearPressTimer])
|
|
|
|
const commit = useCallback(
|
|
async (dir: 'left' | 'right', runAction: boolean) => {
|
|
if (rafRef.current != null) {
|
|
cancelAnimationFrame(rafRef.current)
|
|
rafRef.current = null
|
|
}
|
|
|
|
if (cardRef.current) {
|
|
cardRef.current.style.touchAction = 'pan-y'
|
|
}
|
|
|
|
const w = cardRef.current?.offsetWidth || widthRef.current || 360
|
|
const outDx = dir === 'right' ? w + 56 : -(w + 56)
|
|
|
|
setAnimMs(commitMs)
|
|
setArmedDir(dir)
|
|
dxRef.current = outDx
|
|
setDx(outDx)
|
|
|
|
const animPromise = new Promise<void>((resolve) => {
|
|
window.setTimeout(resolve, commitMs)
|
|
})
|
|
|
|
// Wichtig: erst rausanimieren lassen, dann Aktion starten
|
|
await animPromise
|
|
|
|
let ok: boolean | void = true
|
|
if (runAction) {
|
|
ok = await Promise.resolve(
|
|
dir === 'right' ? onSwipeRight() : onSwipeLeft()
|
|
).catch(() => false)
|
|
}
|
|
|
|
if (ok === false) {
|
|
setAnimMs(snapMs)
|
|
setArmedDir(null)
|
|
setDx(0)
|
|
dxRef.current = 0
|
|
window.setTimeout(() => setAnimMs(0), snapMs)
|
|
return false
|
|
}
|
|
|
|
return true
|
|
},
|
|
[commitMs, onSwipeLeft, onSwipeRight, snapMs]
|
|
)
|
|
|
|
const softResetForTap = useCallback(() => {
|
|
if (rafRef.current != null) {
|
|
cancelAnimationFrame(rafRef.current)
|
|
rafRef.current = null
|
|
}
|
|
|
|
dxRef.current = 0
|
|
setAnimMs(0)
|
|
setDx(0)
|
|
setArmedDir(null)
|
|
|
|
try {
|
|
const el = cardRef.current
|
|
if (el) el.style.touchAction = 'pan-y'
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, [])
|
|
|
|
const runHotFx = useCallback(
|
|
(clientX?: number, clientY?: number) => {
|
|
const outer = outerRef.current
|
|
const card = cardRef.current
|
|
if (!outer || !card) return
|
|
|
|
const layer = getGlobalFxLayer()
|
|
if (!layer) return
|
|
|
|
let startX = typeof clientX === 'number' ? clientX : window.innerWidth / 2
|
|
let startY = typeof clientY === 'number' ? clientY : window.innerHeight / 2
|
|
|
|
const targetEl = hotTargetSelector
|
|
? ((outerRef.current?.querySelector(hotTargetSelector) as HTMLElement | null) ??
|
|
(card.querySelector(hotTargetSelector) as HTMLElement | null))
|
|
: null
|
|
|
|
let endX = startX
|
|
let endY = startY
|
|
if (targetEl) {
|
|
const tr = targetEl.getBoundingClientRect()
|
|
endX = tr.left + tr.width / 2
|
|
endY = tr.top + tr.height / 2
|
|
}
|
|
|
|
const dx = endX - startX
|
|
const dy = endY - startY
|
|
|
|
const flame = document.createElement('div')
|
|
flame.style.position = 'absolute'
|
|
flame.style.left = `${startX}px`
|
|
flame.style.top = `${startY}px`
|
|
flame.style.transform = 'translate(-50%, -50%)'
|
|
flame.style.pointerEvents = 'none'
|
|
flame.style.willChange = 'transform, opacity'
|
|
flame.style.zIndex = '2147483647'
|
|
flame.style.lineHeight = '1'
|
|
flame.style.userSelect = 'none'
|
|
flame.style.filter = 'drop-shadow(0 10px 16px rgba(0,0,0,0.22))'
|
|
|
|
const inner = document.createElement('div')
|
|
inner.style.width = '30px'
|
|
inner.style.height = '30px'
|
|
inner.style.color = '#f59e0b'
|
|
inner.style.display = 'block'
|
|
|
|
flame.appendChild(inner)
|
|
layer.appendChild(flame)
|
|
|
|
const root = createRoot(inner)
|
|
root.render(<FireSolidIcon className="w-full h-full" aria-hidden="true" />)
|
|
|
|
void flame.getBoundingClientRect()
|
|
|
|
const popMs = 200
|
|
const holdMs = 500
|
|
const flyMs = 400
|
|
const duration = popMs + holdMs + flyMs
|
|
|
|
const tPopEnd = popMs / duration
|
|
const tHoldEnd = (popMs + holdMs) / duration
|
|
|
|
const anim = flame.animate(
|
|
[
|
|
{ transform: 'translate(-50%, -50%) scale(0.15)', opacity: 0, offset: 0.0 },
|
|
{ transform: 'translate(-50%, -50%) scale(1.25)', opacity: 1, offset: tPopEnd * 0.55 },
|
|
{ transform: 'translate(-50%, -50%) scale(1.00)', opacity: 1, offset: tPopEnd },
|
|
{ transform: 'translate(-50%, -50%) scale(1.00)', opacity: 1, offset: tHoldEnd },
|
|
{
|
|
transform: `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) scale(0.85)`,
|
|
opacity: 0.95,
|
|
offset: tHoldEnd + (1 - tHoldEnd) * 0.75,
|
|
},
|
|
{
|
|
transform: `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) scale(0.55)`,
|
|
opacity: 0,
|
|
offset: 1.0,
|
|
},
|
|
],
|
|
{
|
|
duration,
|
|
easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)',
|
|
fill: 'forwards',
|
|
}
|
|
)
|
|
|
|
if (targetEl) {
|
|
window.setTimeout(() => {
|
|
try {
|
|
targetEl.animate(
|
|
[
|
|
{ transform: 'translateZ(0) scale(1)', filter: 'brightness(1)' },
|
|
{ transform: 'translateZ(0) scale(1.10)', filter: 'brightness(1.25)', offset: 0.35 },
|
|
{ transform: 'translateZ(0) scale(1)', filter: 'brightness(1)' },
|
|
],
|
|
{ duration: 260, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)' }
|
|
)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, popMs + holdMs + Math.round(flyMs * 0.75))
|
|
}
|
|
|
|
anim.onfinish = () => {
|
|
try {
|
|
root.unmount()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
flame.remove()
|
|
}
|
|
},
|
|
[hotTargetSelector]
|
|
)
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
clearTapTimer()
|
|
clearPressTimer()
|
|
}
|
|
}, [clearTapTimer, clearPressTimer])
|
|
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
swipeLeft: (opts) => commit('left', opts?.runAction ?? true),
|
|
swipeRight: (opts) => commit('right', opts?.runAction ?? true),
|
|
reset: () => reset(),
|
|
}),
|
|
[commit, reset]
|
|
)
|
|
|
|
const absDx = Math.abs(dx)
|
|
const swipeDir: 'left' | 'right' | null = dx === 0 ? null : dx > 0 ? 'right' : 'left'
|
|
const activeThreshold =
|
|
thresholdRef.current || Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
|
|
|
|
const reveal = clamp(absDx / Math.max(1, activeThreshold), 0, 1)
|
|
const revealSoft = clamp(absDx / Math.max(1, activeThreshold * 1.35), 0, 1)
|
|
|
|
const tiltDeg = clamp(dx / 34, -ROTATE_MAX_DEG, ROTATE_MAX_DEG)
|
|
const dragScale = dx === 0 ? 1 : Math.max(SCALE_MIN, 1 - revealSoft * 0.008)
|
|
|
|
return (
|
|
<div ref={outerRef} className={cn('relative isolate overflow-visible rounded-lg', className)}>
|
|
<div
|
|
ref={cardRef}
|
|
className="relative"
|
|
style={{
|
|
transform:
|
|
dx !== 0
|
|
? `translate3d(${dx}px,0,0) rotate(${tiltDeg}deg) scale(${dragScale})`
|
|
: undefined,
|
|
transition: animMs
|
|
? `transform ${animMs}ms cubic-bezier(0.22, 1, 0.36, 1)`
|
|
: undefined,
|
|
touchAction: 'pan-y',
|
|
willChange: dx !== 0 ? 'transform' : undefined,
|
|
borderRadius: dx !== 0 ? '12px' : undefined,
|
|
filter: dx !== 0 ? `saturate(${1 + reveal * 0.06}) brightness(${1 + reveal * 0.015})` : undefined,
|
|
}}
|
|
onPointerDown={(e) => {
|
|
if (!enabled || disabled) return
|
|
|
|
const target = e.target as HTMLElement | null
|
|
|
|
let tapIgnored = Boolean(tapIgnoreSelector && target?.closest?.(tapIgnoreSelector))
|
|
|
|
if (ignoreSelector && target?.closest?.(ignoreSelector)) return
|
|
|
|
let noSwipe = false
|
|
|
|
const root = e.currentTarget as HTMLElement
|
|
const videos = Array.from(root.querySelectorAll('video')) as HTMLVideoElement[]
|
|
const ctlVideo = videos.find((v) => v.controls)
|
|
|
|
if (ctlVideo) {
|
|
const vr = ctlVideo.getBoundingClientRect()
|
|
|
|
const inVideo =
|
|
e.clientX >= vr.left &&
|
|
e.clientX <= vr.right &&
|
|
e.clientY >= vr.top &&
|
|
e.clientY <= vr.bottom
|
|
|
|
if (inVideo) {
|
|
const fromBottomVideo = vr.bottom - e.clientY
|
|
const scrubZonePx = 72
|
|
|
|
if (fromBottomVideo <= scrubZonePx) {
|
|
noSwipe = true
|
|
tapIgnored = true
|
|
} else {
|
|
const edgeZonePx = 64
|
|
const xFromLeft = e.clientX - vr.left
|
|
const xFromRight = vr.right - e.clientX
|
|
const inEdge = xFromLeft <= edgeZonePx || xFromRight <= edgeZonePx
|
|
|
|
if (!inEdge) {
|
|
noSwipe = true
|
|
tapIgnored = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
const fromBottom = rect.bottom - e.clientY
|
|
if (ignoreFromBottomPx && fromBottom <= ignoreFromBottomPx) {
|
|
noSwipe = true
|
|
}
|
|
|
|
endLongPress()
|
|
|
|
// Long-Press nur im Teaser-Modus, nicht im Inline-Playback / Controls / gesperrten Zonen
|
|
if (enablePressHold && !noSwipe && !tapIgnored && onPressStart) {
|
|
const pressInfo = {
|
|
pointerType: e.pointerType,
|
|
button: e.button,
|
|
}
|
|
|
|
const pointerId = e.pointerId
|
|
|
|
pressTimerRef.current = window.setTimeout(() => {
|
|
pressTimerRef.current = null
|
|
|
|
if (pointer.current.id !== pointerId) return
|
|
if (pointer.current.dragging) return
|
|
if (pointer.current.noSwipe) return
|
|
if (pointer.current.tapIgnored) return
|
|
|
|
longPressActiveRef.current = true
|
|
onPressStart(pressInfo)
|
|
}, pressDelayMs)
|
|
}
|
|
|
|
const w = cardRef.current?.offsetWidth || 360
|
|
widthRef.current = w
|
|
thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio)
|
|
|
|
pointer.current = {
|
|
id: e.pointerId,
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
dragging: false,
|
|
captured: false,
|
|
tapIgnored,
|
|
noSwipe,
|
|
axisLocked: null,
|
|
}
|
|
|
|
lastMoveXRef.current = e.clientX
|
|
lastMoveTRef.current = performance.now()
|
|
velocityXRef.current = 0
|
|
dxRef.current = 0
|
|
}}
|
|
onPointerMove={(e) => {
|
|
if (!enabled || disabled) return
|
|
if (pointer.current.id !== e.pointerId) return
|
|
if (pointer.current.noSwipe) return
|
|
if (longPressActiveRef.current) return
|
|
|
|
const ddx = e.clientX - pointer.current.startX
|
|
const ddy = e.clientY - pointer.current.startY
|
|
const absX = Math.abs(ddx)
|
|
const absY = Math.abs(ddy)
|
|
|
|
if (!pointer.current.axisLocked) {
|
|
if (absX < HORIZONTAL_START_PX && absY < HORIZONTAL_START_PX) return
|
|
|
|
if (absY > absX * LOCK_RATIO) {
|
|
clearPressTimer()
|
|
pointer.current.axisLocked = 'y'
|
|
pointer.current.id = null
|
|
return
|
|
}
|
|
|
|
if (absX > absY * LOCK_RATIO) {
|
|
pointer.current.axisLocked = 'x'
|
|
} else {
|
|
return
|
|
}
|
|
}
|
|
|
|
if (pointer.current.axisLocked !== 'x') return
|
|
|
|
if (!pointer.current.dragging) {
|
|
clearPressTimer()
|
|
pointer.current.dragging = true
|
|
;(e.currentTarget as HTMLElement).style.touchAction = 'none'
|
|
setAnimMs(0)
|
|
|
|
try {
|
|
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
pointer.current.captured = true
|
|
} catch {
|
|
pointer.current.captured = false
|
|
}
|
|
}
|
|
|
|
const now = performance.now()
|
|
const dt = Math.max(1, now - lastMoveTRef.current)
|
|
const vx = (e.clientX - lastMoveXRef.current) / dt
|
|
velocityXRef.current = vx
|
|
lastMoveXRef.current = e.clientX
|
|
lastMoveTRef.current = now
|
|
|
|
const next = rubberBand(ddx, widthRef.current)
|
|
flushDx(next)
|
|
|
|
const threshold = thresholdRef.current
|
|
const nextDir = next > threshold ? 'right' : next < -threshold ? 'left' : null
|
|
|
|
setArmedDir((prev) => {
|
|
if (prev === nextDir) return prev
|
|
if (nextDir) {
|
|
try {
|
|
navigator.vibrate?.(10)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
return nextDir
|
|
})
|
|
}}
|
|
onPointerUp={(e) => {
|
|
if (!enabled || disabled) return
|
|
if (pointer.current.id !== e.pointerId) return
|
|
|
|
const threshold = thresholdRef.current || Math.min(thresholdPx, widthRef.current * thresholdRatio)
|
|
|
|
const wasDragging = pointer.current.dragging
|
|
const wasCaptured = pointer.current.captured
|
|
const wasTapIgnored = pointer.current.tapIgnored
|
|
|
|
const wasLongPress = longPressActiveRef.current
|
|
clearPressTimer()
|
|
|
|
pointer.current.id = null
|
|
pointer.current.dragging = false
|
|
pointer.current.captured = false
|
|
pointer.current.axisLocked = null
|
|
|
|
if (wasCaptured) {
|
|
try {
|
|
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
|
|
|
|
if (wasLongPress) {
|
|
longPressActiveRef.current = false
|
|
onPressEnd?.()
|
|
return
|
|
}
|
|
|
|
if (!wasDragging) {
|
|
const now = Date.now()
|
|
const last = lastTapRef.current
|
|
|
|
const isNear =
|
|
last &&
|
|
Math.hypot(e.clientX - last.x, e.clientY - last.y) <= doubleTapMaxMovePx
|
|
|
|
const isDouble = Boolean(onDoubleTap) && last && now - last.t <= doubleTapMs && isNear
|
|
|
|
if (isDouble) {
|
|
lastTapRef.current = null
|
|
clearTapTimer()
|
|
|
|
if (doubleTapBusyRef.current) return
|
|
doubleTapBusyRef.current = true
|
|
|
|
try {
|
|
runHotFx(e.clientX, e.clientY)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
;(async () => {
|
|
try {
|
|
await onDoubleTap?.()
|
|
} finally {
|
|
doubleTapBusyRef.current = false
|
|
}
|
|
})()
|
|
})
|
|
|
|
return
|
|
}
|
|
|
|
if (wasTapIgnored) {
|
|
lastTapRef.current = { t: now, x: e.clientX, y: e.clientY }
|
|
clearTapTimer()
|
|
tapTimerRef.current = window.setTimeout(() => {
|
|
tapTimerRef.current = null
|
|
lastTapRef.current = null
|
|
}, onDoubleTap ? doubleTapMs : 0)
|
|
return
|
|
}
|
|
|
|
softResetForTap()
|
|
|
|
lastTapRef.current = { t: now, x: e.clientX, y: e.clientY }
|
|
clearTapTimer()
|
|
|
|
tapTimerRef.current = window.setTimeout(() => {
|
|
tapTimerRef.current = null
|
|
lastTapRef.current = null
|
|
onTap?.()
|
|
}, onDoubleTap ? doubleTapMs : 0)
|
|
|
|
return
|
|
}
|
|
|
|
if (rafRef.current != null) {
|
|
cancelAnimationFrame(rafRef.current)
|
|
rafRef.current = null
|
|
}
|
|
|
|
const finalDx = dxRef.current
|
|
const abs = Math.abs(finalDx)
|
|
const vx = velocityXRef.current
|
|
const fastRight = vx > FLICK_VELOCITY
|
|
const fastLeft = vx < -FLICK_VELOCITY
|
|
|
|
dxRef.current = 0
|
|
|
|
if (finalDx > threshold || (fastRight && abs >= threshold * 0.5)) {
|
|
void commit('right', true)
|
|
return
|
|
}
|
|
|
|
if (finalDx < -threshold || (fastLeft && abs >= threshold * 0.5)) {
|
|
void commit('left', true)
|
|
return
|
|
}
|
|
|
|
reset()
|
|
}}
|
|
onPointerCancel={(e) => {
|
|
if (!enabled || disabled) return
|
|
endLongPress()
|
|
clearPressTimer()
|
|
|
|
if (pointer.current.captured && pointer.current.id != null) {
|
|
try {
|
|
;(e.currentTarget as HTMLElement).releasePointerCapture(pointer.current.id)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
pointer.current = {
|
|
id: null,
|
|
startX: 0,
|
|
startY: 0,
|
|
dragging: false,
|
|
captured: false,
|
|
tapIgnored: false,
|
|
noSwipe: false,
|
|
axisLocked: null,
|
|
}
|
|
|
|
if (rafRef.current != null) {
|
|
cancelAnimationFrame(rafRef.current)
|
|
rafRef.current = null
|
|
}
|
|
|
|
dxRef.current = 0
|
|
velocityXRef.current = 0
|
|
|
|
try {
|
|
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
reset()
|
|
}}
|
|
>
|
|
<div className="relative">
|
|
<div className="relative z-10">{children}</div>
|
|
|
|
<div
|
|
className="absolute inset-0 z-20 pointer-events-none rounded-lg transition-opacity duration-100"
|
|
style={{
|
|
opacity: dx === 0 ? 0 : 0.08 + revealSoft * 0.16,
|
|
background:
|
|
swipeDir === 'right'
|
|
? 'linear-gradient(90deg, rgba(16,185,129,0.16) 0%, rgba(16,185,129,0.05) 42%, rgba(0,0,0,0) 100%)'
|
|
: swipeDir === 'left'
|
|
? 'linear-gradient(270deg, rgba(244,63,94,0.16) 0%, rgba(244,63,94,0.05) 42%, rgba(0,0,0,0) 100%)'
|
|
: 'transparent',
|
|
}}
|
|
/>
|
|
|
|
<div
|
|
className="absolute inset-0 z-20 pointer-events-none rounded-lg transition-opacity duration-100"
|
|
style={{
|
|
opacity: armedDir ? 1 : 0,
|
|
boxShadow:
|
|
armedDir === 'right'
|
|
? 'inset 0 0 0 1px rgba(16,185,129,0.40), inset 0 0 28px rgba(16,185,129,0.10)'
|
|
: armedDir === 'left'
|
|
? 'inset 0 0 0 1px rgba(244,63,94,0.40), inset 0 0 28px rgba(244,63,94,0.10)'
|
|
: 'none',
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
export default SwipeCard |