Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 66 additions & 21 deletions src/components/QuizMode.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { wordsForLessons } from '../data/lessons.js'
import { filterByScope, useProgress } from '../hooks/useProgress.jsx'
import { hasDistinctReading } from '../lib/text.js'
import { AppBar, Button, Countdown, ProgressBar } from './ui.jsx'
import { AppBar, Badge, Button, Countdown, ProgressBar } from './ui.jsx'

const TIME_LIMIT = 15

Expand All @@ -15,18 +15,61 @@ function shuffle(arr) {
return a
}

// The three faces of a card; any ordered pair (prompt → answer) can be a question.
const SIDE_TEXT = {
meaning: (w) => w.meaning || '',
kanji: (w) => w.word || '',
reading: (w) => (w.reading || '').trim(),
}
const QUESTION_PAIRS = [
['meaning', 'kanji'],
['kanji', 'meaning'],
['meaning', 'reading'],
['reading', 'meaning'],
['kanji', 'reading'],
['reading', 'kanji'],
]
const PROMPT = {
'meaning>kanji': '這個意思的日文是?',
'meaning>reading': '這個意思的讀音是?',
'kanji>meaning': '這個單字的意思是?',
'kanji>reading': '這個漢字的讀音是?',
'reading>meaning': '這個讀音的意思是?',
'reading>kanji': '這個讀音的漢字是?',
}

const sideHasContent = (word, side) => SIDE_TEXT[side](word) !== ''

// kanji and reading share one surface for kana-only words, so that pairing is only a real
// question when the reading differs from the kanji; every other pairing always works.
function pairAllowed(word, [p, a]) {
if (!sideHasContent(word, p) || !sideHasContent(word, a)) return false
if ((p === 'kanji' && a === 'reading') || (p === 'reading' && a === 'kanji')) {
return hasDistinctReading(word)
}
return true
}

// questions drawn from `questionWords`; distractors from the wider `pool`
function buildQuiz(questionWords, pool, count) {
const n = count === 'all' ? questionWords.length : Math.min(count, questionWords.length)
const picked = shuffle(questionWords).slice(0, n)
return picked.map((answer) => {
const distractors = shuffle(pool.filter((w) => w.id !== answer.id)).slice(0, 3)
return {
answer,
options: shuffle([answer, ...distractors]),
// each question randomly asks one direction (like 默寫's kanji/reading)
dir: Math.random() < 0.5 ? 'cn2jp' : 'jp2cn',
const pairs = QUESTION_PAIRS.filter((pr) => pairAllowed(answer, pr))
const [qSide, aSide] = pairs[Math.floor(Math.random() * pairs.length)]
// distractors need content on the answer side and a rendered value distinct from the
// answer and from each other (so e.g. homophone readings don't appear twice)
const seen = new Set([SIDE_TEXT[aSide](answer)])
const distractors = []
for (const w of shuffle(pool)) {
if (w.id === answer.id || !sideHasContent(w, aSide)) continue
const txt = SIDE_TEXT[aSide](w)
if (seen.has(txt)) continue
seen.add(txt)
distractors.push(w)
if (distractors.length === 3) break
}
return { answer, qSide, aSide, options: shuffle([answer, ...distractors]) }
})
}

Expand Down Expand Up @@ -112,17 +155,14 @@ export default function QuizMode({ lessons, scope = 'all', count = 20, timed = t
{/* question */}
<div className="min-h-0 flex-1 overflow-y-auto no-scrollbar">
<div className="px-[22px] pb-5 pt-6 text-center">
{q.dir === 'cn2jp' ? (
<>
<div className="text-xs tracking-[.05em] text-gr4">這個意思的日文是?</div>
<div className="mt-2.5 font-serif text-[34px] font-semibold leading-snug text-blk">{q.answer.meaning}</div>
</>
) : (
<>
<div className="text-xs tracking-[.05em] text-gr4">這個單字的意思是?</div>
<div className="mt-2.5 font-serif text-[34px] font-semibold leading-snug text-blk">{q.answer.word}</div>
{hasDistinctReading(q.answer) && <div className="mt-1 text-sm text-gr4">{q.answer.reading}</div>}
</>
<div className="text-xs tracking-[.05em] text-gr4">{PROMPT[`${q.qSide}>${q.aSide}`]}</div>
<div className="mt-2.5 font-serif text-[34px] font-semibold leading-snug text-blk">{SIDE_TEXT[q.qSide](q.answer)}</div>
{/* furigana hint under a kanji prompt — never when the reading itself is the answer */}
{q.qSide === 'kanji' && q.aSide !== 'reading' && hasDistinctReading(q.answer) && (
<div className="mt-1 text-sm text-gr4">{q.answer.reading}</div>
)}
{q.answer.tags?.[0] && (
<div className="mt-3"><Badge variant="pos">{q.answer.tags[0]}</Badge></div>
)}
</div>

Expand All @@ -144,13 +184,18 @@ export default function QuizMode({ lessons, scope = 'all', count = 20, timed = t
onClick={() => answer(i)}
className={`relative mx-[18px] mb-2.5 flex w-[calc(100%-36px)] items-baseline justify-center gap-2.5 rounded-md border-[.5px] px-9 py-3.5 text-[15px] transition-colors ${cls}`}
>
{q.dir === 'cn2jp' ? (
{q.aSide === 'meaning' ? (
<span className={`text-center ${textColor}`}>{opt.meaning}</span>
) : q.aSide === 'kanji' ? (
<>
<span className={`font-serif text-[17px] ${textColor}`}>{opt.word}</span>
{hasDistinctReading(opt) && <span className="text-[13px] text-current opacity-70">{opt.reading}</span>}
{/* furigana under kanji options, except when the reading was the question */}
{q.qSide !== 'reading' && hasDistinctReading(opt) && (
<span className="text-[13px] text-current opacity-70">{opt.reading}</span>
)}
</>
) : (
<span className={`text-center ${textColor}`}>{opt.meaning}</span>
<span className={`font-serif text-[17px] ${textColor}`}>{opt.reading}</span>
)}
{answered && isCorrect && <span className="absolute right-4 top-1/2 -translate-y-1/2">✓</span>}
{answered && isPicked && !isCorrect && <span className="absolute right-4 top-1/2 -translate-y-1/2">✕</span>}
Expand Down
27 changes: 14 additions & 13 deletions src/components/TypingMode.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { wordsForLessons } from '../data/lessons.js'
import { filterByScope, useProgress } from '../hooks/useProgress.jsx'
import { answersMatch, bareWord, hasDistinctReading } from '../lib/text.js'
import { answersMatch, hasDistinctReading, surfaceForms } from '../lib/text.js'
import { AppBar, Badge, Button, Countdown, ProgressBar } from './ui.jsx'

const TIME_LIMIT = 20
Expand All @@ -26,18 +26,18 @@ function buildDeck(words, count) {
}))
}

/** Evaluate a typed answer. Returns { correct, viaReading }. */
/**
* Evaluate a typed answer. Returns { correct, viaReading }.
* Either the kanji or the reading is accepted regardless of which was asked, and bracketed
* parts are optional. Hiragana/katakana are not interchangeable (handled in normalizeAnswer).
*/
function judge(input, { word, target }) {
if (target === 'reading') {
return { correct: answersMatch(input, word.reading), viaReading: false }
}
// kanji target: accept the word, its bare form, or (leniently) the correct reading
const okKanji = answersMatch(input, word.word) || answersMatch(input, bareWord(word.word))
if (okKanji) return { correct: true, viaReading: false }
if (hasDistinctReading(word) && answersMatch(input, word.reading)) {
return { correct: true, viaReading: true }
}
return { correct: false, viaReading: false }
const okWord = surfaceForms(word.word).some((f) => answersMatch(input, f))
const okReading = surfaceForms(word.reading).some((f) => answersMatch(input, f))
if (!okWord && !okReading) return { correct: false, viaReading: false }
// flag when only the reading matched a question that asked for the kanji
const viaReading = !okWord && target === 'kanji' && hasDistinctReading(word)
return { correct: true, viaReading }
}

export default function TypingMode({ lessons, scope = 'all', count = 20, timed = true, onBack }) {
Expand Down Expand Up @@ -127,7 +127,8 @@ export default function TypingMode({ lessons, scope = 'all', count = 20, timed =
<div className="px-[22px] text-center">
<div className="text-xs tracking-[.05em] text-gr4">這個意思的日文是?</div>
<div className="mt-2.5 font-serif text-[36px] font-semibold leading-snug text-blk">{card.word.meaning}</div>
<div className="mt-3.5">
<div className="mt-3.5 flex items-center justify-center gap-2">
{card.word.tags?.[0] && <Badge variant="pos">{card.word.tags[0]}</Badge>}
<Badge variant="sec">{card.target === 'reading' ? '請輸入讀音(假名)' : '請輸入漢字'}</Badge>
</div>
</div>
Expand Down
53 changes: 39 additions & 14 deletions src/lib/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,54 @@ export function hasDistinctReading(word) {
return r !== '' && r !== (word.word || '').trim()
}

/** Strip decorative markers ((…), […], 〜, spaces) so 質問(する) / [〜を]〜 compare leniently. */
export function bareWord(word) {
const w = typeof word === 'string' ? word : word.word || ''
return w
.replace(/([^)]*)/g, '')
.replace(/[[^]]*]/g, '')
.replace(/[〜~\s]/g, '')
.trim()
const clean = (s) => s.replace(/[〜~\s]/g, '').trim()

/**
* Every surface form we accept for one base string, treating each bracketed segment
* ((…), […], 【…】) as independently optional. We expand the combinations of keeping
* vs dropping each segment's content (so [電話を]かけます(かける) yields かけます,
* 電話をかけます, …) and also add each segment's content alone as an alternative form
* (so あげます(あげる) accepts あげる).
*/
export function surfaceForms(base) {
const b = (typeof base === 'string' ? base : '') || ''
if (!b) return []
const parts = [] // { text, optional }
const re = /[([【]([^)]】]*)[)]】]/g
let last = 0
let m
while ((m = re.exec(b))) {
if (m.index > last) parts.push({ text: b.slice(last, m.index), optional: false })
parts.push({ text: m[1], optional: true })
last = re.lastIndex
}
if (last < b.length) parts.push({ text: b.slice(last), optional: false })

let combos = ['']
for (const p of parts) {
combos = p.optional
? combos.flatMap((c) => [c, c + p.text]) // drop or keep this segment
: combos.map((c) => c + p.text)
}
const set = new Set(combos.map(clean))
for (const p of parts) if (p.optional) set.add(clean(p.text)) // standalone alternative
set.delete('')
Comment on lines +31 to +50
return [...set]
}

// ── Typing-mode fuzzy comparison (P2) ────────────────────────────
const KATA_TO_HIRA = (s) =>
s.replace(/[ァ-ヶ]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x60))

const FULLWIDTH_TO_HALF = (s) =>
s.replace(/[!-~]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0))

/** Normalize an answer for lenient comparison: trim, half-width, hiragana, long-vowel おお/おう unified. */
/**
* Normalize an answer for lenient comparison: trim, half-width, long-vowel おお/ー unified.
* Hiragana and katakana are NOT folded together — かぞく and カゾク stay distinct — so a
* kana answer must use the right script; only kanji↔kana (via surfaceForms) is tolerated.
*/
export function normalizeAnswer(s) {
let t = (s || '').trim()
t = FULLWIDTH_TO_HALF(t)
t = KATA_TO_HIRA(t)
// treat long-vowel variants as equivalent: collapse おう/おお → おう family, ー → preceding vowel
// treat long-vowel variants as equivalent: collapse おお → おう, ー dropped
t = t.replace(/おお/g, 'おう').replace(/ー/g, '')
return t
}
Expand Down