vue3-flashcards
Version:
Tinder-like flashcards component with dragging and flipping
584 lines (485 loc) • 18.4 kB
text/typescript
import type { InjectionKey, MaybeRefOrGetter, Ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, provide, reactive, readonly, ref, toRef, toValue } from 'vue'
import { flashCardsDefaults, resistanceDefaults, velocityDefaults } from '../config/flashcards.config'
export const SwipeAction = {
// Primary directional actions
TOP: 'top',
LEFT: 'left',
RIGHT: 'right',
BOTTOM: 'bottom',
// Special action
SKIP: 'skip',
} as const
export type SwipeAction = typeof SwipeAction[keyof typeof SwipeAction]
export type Direction = 'top' | 'left' | 'right' | 'bottom'
export interface DragSetupParams {
// Distance in pixels the card must be dragged to complete swiping
// If the card is dragged less than this distance, it will be restored to its original position
swipeThreshold?: number
// Distance in pixels the card must be dragged to start swiping, small value
// Is need to prevent false positives (e.x. for card fliping feature)
dragThreshold?: number
// Max dragging in pixels, user cant drag moren than than value
// Disabled by default
maxDragY?: number | null
maxDragX?: number | null
// Direction of swiping, array of specific directions
direction?: Direction[]
// Completely disable dragging feature
disableDrag?: boolean
// Initial position for the card (used for animations)
initialPosition?: DragPosition
// Resistance ("rubber-band") effect when dragging beyond a threshold.
// Disabled by default (`null`). Pass an object to enable, with optional
// `threshold` (px before resistance kicks in) and `strength` (0-1, where 1 is
// maximum resistance). An empty object `{}` uses the defaults for both.
resistance?: ResistanceOptions | null
// Velocity-based ("flick") swipe completion: a fast flick completes the swipe
// even if the card was released before reaching `swipeThreshold`. Enabled by
// default. Pass `null` to disable, or an object to tune the threshold.
// (Disable sentinel is `null`, not `false`, so Vue doesn't infer a Boolean
// prop and coerce an absent value to disabled.)
velocity?: VelocityOptions | null
}
export interface ResistanceOptions {
// Distance (px) the card can move freely before resistance starts. Default 150.
threshold?: number
// Resistance strength, 0-1 (1 = maximum resistance). Default 0.3.
strength?: number
}
export interface VelocityOptions {
// Minimum pointer speed (px/ms) along the dominant axis at release that
// triggers a flick completion. Default 0.5.
threshold?: number
}
export interface DragSetupCallbacks {
onDragStart?: () => void
onDragMove?: (type: SwipeAction | null, delta: number) => void
onDragEnd?: () => void
onDragComplete?: (action: SwipeAction) => void
}
export type DragSetupOptions = DragSetupParams & DragSetupCallbacks
function inferDirectionFromPosition(x: number, y: number): Direction | null {
// If both x and y are 0, no direction
if (x === 0 && y === 0)
return null
// Determine direction based on dominant axis
return Math.abs(x) >= Math.abs(y)
? (x > 0 ? 'right' : 'left')
: (y > 0 ? 'bottom' : 'top')
}
function getDirectionFromPosition(
x: number,
y: number,
enabledDirections: Direction[],
threshold: number,
): Direction | null {
// Find the dominant direction based on distance
const absX = Math.abs(x)
const absY = Math.abs(y)
if (absX > absY) {
// Horizontal is dominant
if (enabledDirections.includes('right') && x > threshold)
return 'right'
if (enabledDirections.includes('left') && x < -threshold)
return 'left'
}
else {
// Vertical is dominant
if (enabledDirections.includes('top') && y < -threshold)
return 'top'
if (enabledDirections.includes('bottom') && y > threshold)
return 'bottom'
}
return null
}
// Resolve the swipe direction implied by a fast flick at release. Unlike
// `getDirectionFromPosition` (distance-based), this looks at the pointer
// velocity along each axis: a quick flick completes the swipe even when the card
// was released short of `swipeThreshold`. The flick must point the same way the
// card was already dragged (sign of velocity matches sign of offset) so a brief
// jitter in the opposite direction never flings the card the wrong way.
function getDirectionFromVelocity(
x: number,
y: number,
vx: number,
vy: number,
enabledDirections: Direction[],
velocityThreshold: number,
): Direction | null {
const absVX = Math.abs(vx)
const absVY = Math.abs(vy)
// Dominant axis is whichever the flick is faster along.
if (absVX >= absVY) {
if (absVX < velocityThreshold)
return null
if (enabledDirections.includes('right') && vx > 0 && x > 0)
return 'right'
if (enabledDirections.includes('left') && vx < 0 && x < 0)
return 'left'
}
else {
if (absVY < velocityThreshold)
return null
if (enabledDirections.includes('top') && vy < 0 && y < 0)
return 'top'
if (enabledDirections.includes('bottom') && vy > 0 && y > 0)
return 'bottom'
}
return null
}
// For nested components to notify about dragging state
export const IsDraggingStateInjectionKey = Symbol('is-dragging-key') as InjectionKey<Readonly<Ref<boolean>>>
export interface DragPosition {
x: number
y: number
delta: number
type?: SwipeAction | null
}
export function useDragSetup(el: MaybeRefOrGetter<HTMLDivElement | null>, _options: MaybeRefOrGetter<DragSetupOptions>) {
const element = toRef(el)
const options = computed(() => toValue(_options))
const {
onDragStart = () => {},
onDragMove = () => {},
onDragEnd = () => {},
onDragComplete = () => {},
} = options.value
const swipeThreshold = computed(() => options.value.swipeThreshold ?? flashCardsDefaults.swipeThreshold)
const dragThreshold = computed(() => options.value.dragThreshold ?? flashCardsDefaults.dragThreshold)
const maxDragY = computed(() => options.value.maxDragY ?? null)
const maxDragX = computed(() => options.value.maxDragX ?? null)
const swipeDirection = computed(() => options.value.direction)
const enabledDirections = computed(() => swipeDirection.value)
// Resistance is off unless `resistance` is an object. Each field falls back to
// the library default.
const resistanceEffect = computed(() => !!options.value.resistance)
const resistanceThreshold = computed(() => {
const r = options.value.resistance
return (r && r.threshold) ?? resistanceDefaults.threshold
})
const resistanceStrength = computed(() => {
const r = options.value.resistance
return (r && r.strength) ?? resistanceDefaults.strength
})
// Velocity ("flick") completion is ON by default. `velocity: null` disables
// it; an object tunes the threshold. (We use `null`, not `false`, as the
// disable sentinel so Vue doesn't infer a Boolean prop and coerce an absent
// value to `false` — that would silently disable the default-on behaviour.)
const swipeVelocityEnabled = computed(() => options.value.velocity !== null)
const swipeVelocityThreshold = computed(() => {
const v = options.value.velocity
return (v && v.threshold) ?? velocityDefaults.threshold
})
// Is drag started
const isDragStarted = ref(false)
// Is dragging in progress
const isDragging = ref(false)
// Track active pointer ID for multi-touch prevention
const activePointerId = ref<number | null>(null)
let startX = 0
let startY = 0
// Recent pointer samples (position + timestamp) used to estimate release
// velocity for flick detection. We keep a short trailing window so the
// velocity reflects the final motion, not the whole gesture.
const VELOCITY_SAMPLE_WINDOW = 100 // ms
// Minimum time the samples must span before we trust a velocity reading.
// Below this, a few near-instant moves (e.g. a synthetic/programmatic jump)
// would read as an implausibly high speed, so we treat velocity as zero.
const MIN_VELOCITY_INTERVAL = 5 // ms
let samples: { x: number, y: number, t: number }[] = []
function now() {
return typeof performance !== 'undefined' ? performance.now() : Date.now()
}
function recordSample(x: number, y: number) {
const t = now()
samples.push({ x, y, t })
// Drop samples older than the trailing window (keep at least one fallback).
while (samples.length > 1 && t - samples[0].t > VELOCITY_SAMPLE_WINDOW)
samples.shift()
}
// Velocity (px/ms) along each axis from the oldest in-window sample to the last.
function getVelocity() {
if (samples.length < 2)
return { vx: 0, vy: 0 }
const first = samples[0]
const last = samples[samples.length - 1]
const dt = last.t - first.t
if (dt < MIN_VELOCITY_INTERVAL)
return { vx: 0, vy: 0 }
return {
vx: (last.x - first.x) / dt,
vy: (last.y - first.y) / dt,
}
}
// Provide dragging state to nested components
provide(IsDraggingStateInjectionKey, readonly(isDragging))
const initialPos = options.value.initialPosition
const position = reactive<DragPosition>({
x: initialPos?.x || 0,
y: initialPos?.y || 0,
delta: initialPos?.delta || 0,
type: initialPos?.type ?? inferDirectionFromPosition(initialPos?.x ?? 0, initialPos?.y ?? 0),
})
function restore() {
Object.assign(position, {
x: 0,
y: 0,
delta: 0,
type: null,
})
}
/**
* Programmatically move the card to `percent` (0-1) of the swipe threshold
* along `direction`, WITHOUT completing the swipe. The same `position` the
* drag drives is updated, so `transformStyle` (rotation/scale) and the
* directional indicators react exactly as they do mid-drag. Because dragging
* is not active, the card's CSS transition animates the move smoothly.
*
* Useful for hints ("wobble" the card with `peek(0.1, 'right')` then
* `peek(0, 'right')`) and for keyboard-driven swiping: `peek(1, 'left')` shows
* the full pre-swipe pose while we wait for the user to confirm.
*
* A direction that is not enabled is ignored (the card stays put) so peek can
* never reveal a pose the user could not reach by dragging.
*/
function peek(percent: number, direction: Direction) {
const enabled = enabledDirections.value || []
if (!enabled.includes(direction))
return
const dir = direction
// Clamp to [0, 1] then map onto the swipe threshold distance. A peek of 0
// is a reset to center.
const p = Math.max(0, Math.min(1, percent))
if (p === 0) {
restore()
return
}
let distance = p * swipeThreshold.value
const horizontal = dir === 'left' || dir === 'right'
if (horizontal && maxDragX.value !== null)
distance = Math.min(distance, maxDragX.value)
if (!horizontal && maxDragY.value !== null)
distance = Math.min(distance, maxDragY.value)
let x = 0
let y = 0
switch (dir) {
case 'right':
x = distance
break
case 'left':
x = -distance
break
case 'bottom':
y = distance
break
case 'top':
y = -distance
break
}
// delta carries the swipe sign the transform/indicators expect: positive for
// right/top, negative for left/bottom (matching `handleDragEnd`).
const sign = dir === 'right' || dir === 'top' ? 1 : -1
Object.assign(position, {
x,
y,
delta: sign * p,
type: dir,
})
}
function getDominantAxis(absX: number, absY: number, enabled: Direction[]) {
const hasH = enabled.includes('left') || enabled.includes('right')
const hasV = enabled.includes('top') || enabled.includes('bottom')
if (hasH && !hasV)
return 'horizontal'
if (!hasH && hasV)
return 'vertical'
// true bidirectional
return absX >= absY ? 'horizontal' : 'vertical'
}
function handleDragStart(event: PointerEvent) {
isDragStarted.value = true
activePointerId.value = event.pointerId
startX = event.clientX - position.x
startY = event.clientY - position.y
samples = []
recordSample(position.x, position.y)
onDragStart()
}
function handleDragMove(event: PointerEvent) {
if (!isDragStarted.value)
return
// Multi-touch prevention: ignore events from different pointers
if (activePointerId.value !== null && event.pointerId !== activePointerId.value) {
return
}
const clientX = event.clientX
const clientY = event.clientY
const x = clientX - startX
const y = clientY - startY
const distance = Math.sqrt(x * x + y * y)
if (distance < dragThreshold.value)
return
event.preventDefault()
event.stopPropagation()
isDragging.value = true
let limitedX = x
let limitedY = y
if (maxDragX.value !== null)
limitedX = Math.max(-maxDragX.value, Math.min(maxDragX.value, x))
if (maxDragY.value !== null)
limitedY = Math.max(-maxDragY.value, Math.min(maxDragY.value, y))
const absX = Math.abs(limitedX)
const absY = Math.abs(limitedY)
const axis = getDominantAxis(absX, absY, enabledDirections.value || [])
const isHorizontal = axis === 'horizontal'
let primaryAxis = isHorizontal ? limitedX : -limitedY
let currentDirection: Direction | null = null
if (absX > dragThreshold.value || absY > dragThreshold.value) {
if (isHorizontal)
currentDirection = limitedX > 0 ? 'right' : 'left'
else
currentDirection = limitedY > 0 ? 'bottom' : 'top'
}
// Resistance
if (resistanceEffect.value && Math.abs(primaryAxis) > resistanceThreshold.value) {
const excess = Math.abs(primaryAxis) - resistanceThreshold.value
const resistanceMultiplier = 1 / (1 + excess * resistanceStrength.value / 35)
const resistedExcess = excess * resistanceMultiplier
const dir = primaryAxis >= 0 ? 1 : -1
const resistancePos = resistanceThreshold.value + resistedExcess
if (isHorizontal) {
limitedX = resistancePos * dir
primaryAxis = limitedX
}
else {
limitedY = -resistancePos * dir
primaryAxis = -limitedY
}
}
const delta = Math.max(-1, Math.min(1, primaryAxis / swipeThreshold.value))
position.x = limitedX
position.y = limitedY
position.delta = delta
position.type = currentDirection
recordSample(limitedX, limitedY)
onDragMove(position.type, position.delta)
}
function handleDragEnd() {
if (!isDragStarted.value) {
return
}
isDragStarted.value = false
isDragging.value = false
activePointerId.value = null
// Determine if swipe completion threshold is reached
let completedDirection = getDirectionFromPosition(
position.x,
position.y,
enabledDirections.value || [],
swipeThreshold.value,
)
// Fall back to velocity-based ("flick") completion: a quick release short of
// the distance threshold still completes the swipe if it was fast enough.
if (!completedDirection && swipeVelocityEnabled.value) {
const { vx, vy } = getVelocity()
completedDirection = getDirectionFromVelocity(
position.x,
position.y,
vx,
vy,
enabledDirections.value || [],
swipeVelocityThreshold.value,
)
}
samples = []
if (completedDirection) {
onDragComplete(completedDirection)
// Update position to reflect completion
switch (completedDirection) {
case 'right':
position.delta = 1
break
case 'left':
position.delta = -1
break
case 'top':
position.delta = 1
break
case 'bottom':
position.delta = -1
break
}
position.type = completedDirection
}
else {
restore()
}
onDragEnd()
}
// Prevent scroll on any element while dragging
function handleTouchMove(event: TouchEvent) {
if (isDragging.value) {
event.preventDefault()
event.stopPropagation()
}
}
// Prevent wheel scroll while dragging
function handleWheel(event: WheelEvent) {
if (isDragging.value) {
event.preventDefault()
event.stopPropagation()
}
}
function setupInteract({ applyInitialPosition = true }: { applyInitialPosition?: boolean } = {}) {
// Apply the initial position ONLY when asked (on mount / explicit setup). The
// `disableDrag` watcher re-runs setup just to (de)attach listeners — it must
// NOT re-stamp `initialPosition`, or a card that finished swiping and is being
// reused (loop mode) would snap back to its old release offset. See the
// `disableDrag` watcher in FlashCard.vue.
if (applyInitialPosition) {
const initialPos = options.value.initialPosition
Object.assign(position, {
x: initialPos?.x || 0,
y: initialPos?.y || 0,
delta: initialPos?.delta || 0,
type: initialPos?.type ?? inferDirectionFromPosition(initialPos?.x ?? 0, initialPos?.y ?? 0),
})
}
// Don't add event listeners if dragging is disabled
if (options.value.disableDrag) {
return
}
// Touch events with passive optimization
element.value?.addEventListener('pointerdown', handleDragStart, { passive: false })
window.addEventListener('pointermove', handleDragMove, { passive: false })
window.addEventListener('pointerup', handleDragEnd, { passive: true })
// Add global scroll prevention listeners (with passive: false to allow preventDefault)
document.addEventListener('touchmove', handleTouchMove, { passive: false })
document.addEventListener('wheel', handleWheel, { passive: false })
}
onMounted(async () => {
await nextTick()
setupInteract()
})
function cleanupInteract() {
element.value?.removeEventListener('pointerdown', handleDragStart)
window.removeEventListener('pointermove', handleDragMove)
window.removeEventListener('pointerup', handleDragEnd)
// Remove global scroll prevention listeners
document.removeEventListener('touchmove', handleTouchMove)
document.removeEventListener('wheel', handleWheel)
}
onUnmounted(() => {
cleanupInteract()
})
return {
setupInteract,
cleanupInteract,
position,
isDragging,
restore,
peek,
getDominantAxis,
}
}