lexivo/app/javascript/controllers/flashcard_controller.js
Fibe Agent e9f5d8ece2
Some checks failed
CI / Lint & Test (push) Has been cancelled
Deploy Status Page / Build & Deploy (push) Has been cancelled
Lexivo: vocabulary learning app with SM-2 spaced repetition
- Word import (multi-format: word, word-def, word:def, word|def)
- Flashcard filter UI (swipe + keyboard arrows + Space to flip)
- SM-2 spaced repetition review queue
- Stimulus flashcard controller with 3D flip animation
2026-04-22 15:02:45 +00:00

126 lines
3.8 KiB
JavaScript

import { Controller } from "@hotwired/stimulus"
// Handles flashcard flip + keyboard + swipe for both filter and review pages.
// Expected DOM:
// data-controller="flashcard"
// data-flashcard-target="card" (the 3D flippable element)
//
// Hidden forms in the same element:
// id="form-right" — known / easy
// id="form-left" — learning / forgot
// id="form-skip" — skip (filter only)
// id="form-q3" — hard (review only)
export default class extends Controller {
static targets = ["card"]
connect() {
this.flipped = false
this.animating = false
this.touchStartX = 0
this.touchStartY = 0
this._keydown = this.handleKeydown.bind(this)
document.addEventListener("keydown", this._keydown)
}
disconnect() {
document.removeEventListener("keydown", this._keydown)
}
// ── Keyboard ──────────────────────────────────────────────────────────────
handleKeydown(e) {
if (this.animating) return
switch (e.key) {
case " ":
e.preventDefault()
this.flip()
break
case "ArrowDown":
e.preventDefault()
this.flip()
break
case "ArrowRight":
e.preventDefault()
this.slideAndSubmit("slide-right", "form-right")
break
case "ArrowLeft":
e.preventDefault()
this.slideAndSubmit("slide-left", "form-left")
break
case "s":
case "S":
case "ArrowUp":
e.preventDefault()
this.slideAndSubmit("slide-up", "form-skip")
break
case "1":
e.preventDefault()
this.slideAndSubmit("slide-left", "form-left")
break
case "2":
e.preventDefault()
this.slideAndSubmit("slide-up", "form-q3")
break
case "3":
e.preventDefault()
this.slideAndSubmit("slide-right", "form-right")
break
}
}
// ── Touch / swipe ─────────────────────────────────────────────────────────
touchstart(e) {
const t = e.changedTouches[0]
this.touchStartX = t.clientX
this.touchStartY = t.clientY
}
touchend(e) {
if (this.animating) return
const t = e.changedTouches[0]
const dx = t.clientX - this.touchStartX
const dy = t.clientY - this.touchStartY
// Small movement = tap, handled by click -> flip
if (Math.abs(dx) < 20 && Math.abs(dy) < 20) return
e.preventDefault() // prevent the click event that follows
if (Math.abs(dx) >= Math.abs(dy) && Math.abs(dx) > 40) {
dx > 0
? this.slideAndSubmit("slide-right", "form-right")
: this.slideAndSubmit("slide-left", "form-left")
}
}
// ── Card flip ─────────────────────────────────────────────────────────────
flip(e) {
if (e) e.stopPropagation()
this.flipped = !this.flipped
this.cardTarget.classList.toggle("is-flipped", this.flipped)
}
// ── Form submission ───────────────────────────────────────────────────────
slideAndSubmit(animClass, formId) {
this.animating = true
this.cardTarget.classList.add(animClass)
setTimeout(() => {
const form = this.element.querySelector(`#${formId}`)
if (form) {
form.requestSubmit()
} else {
// form not present on this page variant — ignore silently
this.animating = false
this.cardTarget.classList.remove(animClass)
}
}, 220)
}
}