UNPKG

vue3-flashcards

Version:

Tinder-like flashcards component with dragging and flipping

584 lines (485 loc) 18.4 kB
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, } }