UNPKG

vue3-flashcards

Version:

Tinder-like flashcards component with dragging and flipping

560 lines (502 loc) 22.4 kB
import type { MaybeRefOrGetter } from 'vue' import type { DragPosition, SwipeAction } from './useDragSetup' import { computed, reactive, ref, toValue, watch } from 'vue' import { devWarn } from './devWarn' /** * Lifecycle of a single card, modelled as an explicit state machine. * * A card is never simply "in the deck" or "gone" — the swipe/restore gestures * are animated, so a card spends time in transient states where it's visually * moving but not yet logically committed. Modelling this as an explicit state * machine (rather than an ad-hoc mix of a history map + a transition map + an * `isRestoring` flag) is what makes the tricky gestures correct and readable: * * pending ──swipe──▶ swiping ──animationEnd──▶ swiped * ▲ │ │ * │ restore restore * │ ▼ ▼ * └──animationEnd── restoring ◀───────────────┘ * │ * swipe (restore→next): restoring ──▶ swiping * * Where each state lives (see the two structures inside the composable): * - `pending` : no record anywhere — absence IS the pending state. * - `swiping` : a `records` entry. Animating off-screen, NOT yet committed. * A restore can still catch it (fast swipe → restore). * - `restoring` : a `records` entry. Animating back in; counts as not-active so * the active card does NOT jump (restore isn't "active" until * its animation finishes). A new swipe overwrites it, so * restore → fast next just cancels the restore. * - `swiped` : a `history` entry (NOT in `records`). Committed and consumed. * * Splitting swiped (history) from in-flight (records) keeps `records` tiny — * bounded by overlapping animations, never by deck size — so its iterations * stay cheap as the deck grows. */ export type CardState = 'swiping' | 'restoring' export interface CardRecord { state: CardState action: SwipeAction // swipe direction / type initialPosition?: DragPosition // In loop mode, the cycle this record was created in. A `swiping` card that // outlives a loop rewind (its fly-out finishes AFTER the deck cycled back) // belongs to a stale cycle: it must NOT count as consumed in the new cycle and // must NOT commit to `history` when it lands. Undefined outside loop mode. cycle?: number } export interface StackItem<T> { item: T itemId: string | number stackIndex: number // Current position in stack (0 = top) isAnimating?: boolean // The in-flight animation descriptor for this card (a "flight"). Internal // wiring only: FlashCard consumes it to drive its WAAPI fly-out/restore. NOT // the public `animation` config prop (keyframes/duration/easing). flight?: { type: SwipeAction isRestoring: boolean initialPosition?: DragPosition } } export interface StackListOptions<T> { items: T[] loop?: boolean renderLimit: number itemKey?: keyof T waitAnimationEnd?: boolean onLoop?: () => void } export interface ResetOptions { animate?: boolean delay?: number } export function useStackList<T extends Record<string, unknown>>(_options: MaybeRefOrGetter<StackListOptions<T>>) { const options = computed(() => toValue(_options)) // ------------------------------------------------------------------------- // Card lifecycle, split across two structures for performance — but driven by // ONE explicit state machine (see CardState docs): // // records : only IN-FLIGHT cards (`swiping` / `restoring`). Always small // (bounded by how many animations overlap), so every iteration // over it is cheap regardless of deck size. // history : committed (`swiped`) cards. Grows with the deck, but is only // ever touched by O(1) get/has/set/delete — never iterated on the // hot path. // // A card's state is the union: in `records` => its record.state; else in // `history` => `swiped`; else `pending`. The transition functions keep the // two in lock-step so this union is always consistent. // ------------------------------------------------------------------------- const records = reactive(new Map<string | number, CardRecord>()) const history = reactive(new Map<string | number, SwipeAction>()) // Monotonic loop-cycle counter. Bumped every time the deck rewinds (loop // mode). Records stamped with an older cycle are "stale" — they belong to a // deck that no longer exists, so they're ignored by the active-card scan and // never committed when their fly-out finally lands. (Stays 0 outside loop.) const cycle = ref(0) // True while any card is animating (swiping out or restoring back in). const hasCardsInTransition = computed(() => records.size > 0) // Generate ID for card function getId(item: T, index: number): string | number { if (!item) return index const trackKey = options.value.itemKey || 'id' return item[trackKey as keyof T] as string | number ?? index } // ------------------------------------------------------------------------- // Dev-only sanity checks. Identity tracking is the silent footgun here: when // `itemKey` is omitted and items have no `id`, `getId` falls back to the array // INDEX. That's fine for a static deck, but the moment `items` is mutated (or // `loop` rewinds), index-based ids reassign cards to the wrong history/flight // records — cards flicker, restore the wrong one, or get stuck. Warn so the // cause isn't a mystery. All stripped from production builds. // ------------------------------------------------------------------------- if (import.meta.env.DEV) { watch( () => { const { items, itemKey } = options.value return { len: items.length, sample: items[0], itemKey } }, ({ sample, itemKey }) => { if (itemKey || !sample) return if (!(typeof sample === 'object' && sample !== null && 'id' in sample)) { devWarn( 'missing-item-key', 'No `itemKey` set and items have no `id` field, so cards are tracked ' + 'by array index. This breaks `loop` and any runtime mutation of ' + '`items` (cards may flicker or restore the wrong one). Pass ' + '`itemKey` pointing to a stable unique field.', ) } }, { immediate: true }, ) } /** * Map of itemId -> index in the source array. Rebuilt only when `items` * actually changes, turning the scattered `findIndex` scans into O(1) lookups. */ const idToIndex = computed(() => { const { items } = options.value const map = new Map<string | number, number>() for (let i = 0; i < items.length; i++) map.set(getId(items[i], i), i) return map }) function indexOfId(itemId: string | number): number { return idToIndex.value.get(itemId) ?? -1 } /** * A card is "consumed" (no longer the active card) when it's committed * (`swiped`, in history) or animating away (`swiping`). A `restoring` card is * NOT consumed, but it sits behind the cursor so the forward scan skips it. * O(1): two map lookups. * * A `swiping` record left over from a previous loop cycle is NOT consumed: in * the new cycle that same card is a fresh, active card again, even though its * old fly-out animation hasn't finished yet. Honouring it would make the deck * skip (or hide) a live card on the cycle boundary. */ function isConsumed(itemId: string | number): boolean { if (history.has(itemId)) return true const rec = records.get(itemId) return rec?.state === 'swiping' && !isStale(rec) } // A record is stale once the deck has cycled past the cycle it was stamped in. function isStale(rec: CardRecord): boolean { return rec.cycle !== undefined && rec.cycle !== cycle.value } // ------------------------------------------------------------------------- // Active-card cursor (O(1) amortized lookups). `cursorId` is a forward-scan // start hint, moved explicitly on swipe/restore/reset/loop. Resilient to // runtime mutations of `items`: cards inserted before the cursor never steal // focus (we only scan forward); a removed cursor card restarts from 0. // ------------------------------------------------------------------------- const cursorId = ref<string | number | null>(null) function resolveActiveIndex(startIndex: number): number { const { items } = options.value for (let i = Math.max(0, startIndex); i < items.length; i++) { if (!isConsumed(getId(items[i], i))) return i } return items.length } // Position of the active card (first non-consumed), or items.length at end. const currentIndex = computed(() => { const hintIdx = cursorId.value === null ? 0 : indexOfId(cursorId.value) return resolveActiveIndex(hintIdx === -1 ? 0 : hintIdx) }) // ID of the active card (a real id, or the number items.length at end-of-deck // — unchanged public activeItemKey contract). const currentItemId = computed<string | number>(() => { const { items } = options.value return getId(items[currentIndex.value], currentIndex.value) }) // Advance the cursor hint to the active card after a transition. function syncCursor() { const { items } = options.value const idx = currentIndex.value cursorId.value = idx < items.length ? getId(items[idx], idx) : null } // ------------------------------------------------------------------------- // Derived view preserving the original public shape: `cardsInTransition` // looks like the old list of animating StackItems. (`history` is the Map // declared above — consumers read history.get/has/size directly.) // // IMPORTANT: StackItem objects (and their nested `flight` object) must keep // a STABLE identity for as long as the underlying record is unchanged. // FlashCard.vue watches `flight` by reference to drive its WAAPI animation // and treats a new reference as "animation replaced" (cancel + re-run) — so // rebuilding these objects on every recompute would falsely restart in-flight // animations when a sibling card starts animating (e.g. fast swipe → fast // restore). We cache by itemId and only rebuild when the record's meaningful // fields change. // ------------------------------------------------------------------------- const stackItemCache = new Map<string | number, { rec: CardRecord, stackItem: StackItem<T> }>() function getTransitionStackItem(itemId: string | number, rec: CardRecord): StackItem<T> { const cached = stackItemCache.get(itemId) if ( cached && cached.rec.state === rec.state && cached.rec.action === rec.action && cached.rec.initialPosition === rec.initialPosition ) { return cached.stackItem } const idx = indexOfId(itemId) const stackItem: StackItem<T> = { item: options.value.items[idx] as T, itemId, stackIndex: 0, isAnimating: true, flight: { type: rec.action, isRestoring: rec.state === 'restoring', initialPosition: rec.initialPosition, }, } stackItemCache.set(itemId, { rec, stackItem }) return stackItem } // Cards currently animating in the DOM. `records` holds ONLY in-flight cards // (swiping/restoring), so this is bounded by overlapping animations, not deck // size. const transitionList = computed<StackItem<T>[]>(() => { const result: StackItem<T>[] = [] for (const [id, rec] of records) { // A stale swiping record (its fly-out outlived a loop rewind) keeps // animating off-screen — UNLESS the same item has already become the live // active card in the new cycle (fast-swipe wrapped all the way back to it). // In that one case rendering it as an animating ghost would duplicate the // vnode key and steal the active card, so skip it: the deck renders the // fresh live card instead (its FlashCard cancels the leftover fly-out). if (isStale(rec) && id === currentItemId.value) continue if (indexOfId(id) !== -1) result.push(getTransitionStackItem(id, rec)) } // Drop cache entries for cards that left transition, so they rebuild fresh // if they animate again later. if (stackItemCache.size > records.size) { for (const id of stackItemCache.keys()) { if (!records.has(id)) stackItemCache.delete(id) } } return result }) // Number of cards currently restoring (used to look ahead for isStart/isEnd). const restoringCount = computed(() => { let n = 0 for (const rec of records.values()) { if (rec.state === 'restoring') n++ } return n }) // Expected index once in-flight restores settle — keeps isStart/isEnd stable. const expectedIndex = computed(() => currentIndex.value - restoringCount.value) // ------------------------------------------------------------------------- // Loop mode: on a completed cycle, clear committed cards and rewind. // ------------------------------------------------------------------------- watch(expectedIndex, (ci) => { const { loop, items, onLoop } = options.value if (loop && ci === items.length) { // Drop all committed cards and rewind. In-flight fly-outs are left running // (they finish visually), but they now belong to the OLD cycle: bumping // `cycle` marks their records stale so they neither block the fresh active // card nor re-commit when they land. See `isStale` / `removeAnimatingCard`. history.clear() cursorId.value = null cycle.value++ onLoop?.() } }) // ------------------------------------------------------------------------- // Stack generation (animating cards first, then the forward window). // ------------------------------------------------------------------------- function generateStackItems(startIndex: number, limit: number, items: T[], loop: boolean): StackItem<T>[] { const result: StackItem<T>[] = [] const len = items.length const animatingAdded = new Set<string | number>() // Animating cards (swiping/restoring) render on top. for (const card of transitionList.value) { result.push(card) animatingAdded.add(card.itemId) } // Then the regular forward window. for (let i = 0; i < limit; i++) { const index = loop ? (startIndex + i + len) % len : startIndex + i if (!loop && index >= len) break const item = items[index] const itemId = getId(item, index) if (!animatingAdded.has(itemId)) { result.push({ item, itemId, stackIndex: i, isAnimating: false }) } } return result } const stackList = computed(() => { const { renderLimit, items, loop = false } = options.value if (!items.length) return [] return generateStackItems(currentIndex.value, renderLimit, items, loop) }) const isStart = computed(() => expectedIndex.value === 0) const isEnd = computed(() => expectedIndex.value >= options.value.items.length) /** * Can restore if there's a committed-or-swiping card before the active one. * (A `swiping` card counts — fast swipe → restore must be able to catch it.) */ const canRestore = computed(() => { if (options.value.items.length <= 1 || expectedIndex.value === 0) return false const { items } = options.value for (let i = expectedIndex.value - 1; i >= 0; i--) { const id = getId(items[i], i) const rec = records.get(id) // committed (history) or still swiping away — both are restorable. // A stale swiping record (old loop cycle) is not part of this deck. if (history.has(id) || (rec?.state === 'swiping' && !isStale(rec))) return true } return false }) // ------------------------------------------------------------------------- // Transitions // ------------------------------------------------------------------------- /** * swipe: pending/swiped/restoring ──▶ swiping * Overwriting an existing record is intentional: restore → fast next lands * here and simply cancels the in-flight restore by switching it to swiping. */ function swipeCard(itemId: string | number, type: SwipeAction, initialPosition?: DragPosition): T | undefined { const { items, waitAnimationEnd } = options.value // Block new actions while something animates, if requested. if (hasCardsInTransition.value && waitAnimationEnd) return const idx = indexOfId(itemId) if (idx === -1) return const item = items[idx] // Card is now in-flight again; it lives in `records`, not `history` // (covers restore → fast next: a restoring card that was still committed). records.set(itemId, { state: 'swiping', action: type, initialPosition, cycle: cycle.value }) history.delete(itemId) // The swiped card is now consumed; advance the cursor. syncCursor() return item } /** * Swipe whatever card is "active" for a button-triggered action. * * Normally that's the current card. But if a restore is mid-animation, the * intent of pressing next/swipe is to act on the card being restored (restore * → fast next cancels the restore and sends it back out). This encapsulates * that target-selection so consumers don't need to know about the state * machine — they just call swipeActive(type). */ function swipeActive(type: SwipeAction): T | undefined { // A restoring card takes priority as the action target. With several cards // restoring at once (e.g. two fast restores), the target is the TOPMOST card // the user sees — the one with the highest z-index. // // z-index of animating cards follows `records` INSERTION order (see the // stack template: animating cards render in `transitionList` / `records` // order, later ones on top). Restores run back-to-front (descending array // index), so the LAST-inserted restoring record — the last one iterated, NOT // the highest array index — is the one on top. Picking by array index would // swipe the card UNDERNEATH the one the user is looking at. let target: string | number | undefined for (const [id, rec] of records) { if (rec.state === 'restoring') target = id // keep overwriting → ends on the last (topmost) one } if (target !== undefined) return swipeCard(target, type) // Otherwise act on the current active card, if it's not already animating. const id = currentItemId.value if (indexOfId(id) !== -1 && !records.has(id)) return swipeCard(id, type) return undefined } /** * restore: finds the most recent committed (`swiped`) or in-flight (`swiping`) * card before the active one and moves it to `restoring`. The active card does * NOT change yet — the restored card only becomes active when its animation * finishes (so restore → fast next can cancel it cleanly). */ function restoreCard(): T | undefined { if (!canRestore.value) return const { items } = options.value // Search backward from the active card for something to restore. for (let i = expectedIndex.value - 1; i >= 0; i--) { const itemId = getId(items[i], i) const rec = records.get(itemId) if (rec?.state === 'restoring') continue // already on its way back // The action is whatever it was swiped as — from the in-flight record if // it's still swiping, otherwise from committed history. const action = rec?.state === 'swiping' ? rec.action : history.get(itemId) if (action) { records.set(itemId, { state: 'restoring', action, initialPosition: rec?.initialPosition }) return items[i] } } return undefined } /** * animationEnd: resolve a transient state to its committed form. * swiping ──▶ swiped (commit; in loop mode only if it's still this cycle) * restoring ──▶ pending (delete record; card returns to the deck) */ function removeAnimatingCard(itemId: string | number) { const rec = records.get(itemId) if (!rec) return if (rec.state === 'restoring') { records.delete(itemId) history.delete(itemId) // Restored card sits behind the cursor; point the cursor at it directly. moveCursorTo(itemId) } else if (rec.state === 'swiping') { // A fly-out that outlived a loop rewind belongs to the old cycle: just drop // its record. Committing it would mark a card the new cycle treats as fresh // as already-swiped (a phantom gap). The card is already pending again, so // dropping is all that's needed. if (isStale(rec)) records.delete(itemId) else commit(itemId, rec) syncCursor() } } // swiping ──▶ swiped: drop the in-flight record, commit to history. function commit(itemId: string | number, rec: CardRecord) { records.delete(itemId) history.set(itemId, rec.action) } function moveCursorTo(itemId: string | number) { cursorId.value = itemId } // ------------------------------------------------------------------------- // Reset // ------------------------------------------------------------------------- async function reset(resetOptions?: ResetOptions) { if (resetOptions?.animate) { // Cascade: restore committed cards one by one for a fanning effect. const committed = [...history.keys()] for (let i = 0; i < committed.length; i++) { if (restoreCard()) await new Promise(resolve => setTimeout(resolve, resetOptions?.delay ?? 90)) } // Records clear themselves as restore animations finish. } else { records.clear() history.clear() cursorId.value = null } } return { history, currentIndex, isStart, isEnd, canRestore, stackList, swipeCard, swipeActive, restoreCard, removeAnimatingCard, reset, hasCardsInTransition, currentItemId, cardsInTransition: transitionList, } }