- 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
126 lines
3.8 KiB
JavaScript
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)
|
|
}
|
|
}
|