diff --git a/src/components/QuizMode.jsx b/src/components/QuizMode.jsx index 5a00a45..51691ac 100644 --- a/src/components/QuizMode.jsx +++ b/src/components/QuizMode.jsx @@ -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 @@ -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]) } }) } @@ -112,17 +155,14 @@ export default function QuizMode({ lessons, scope = 'all', count = 20, timed = t {/* question */}
- {q.dir === 'cn2jp' ? ( - <> -
這個意思的日文是?
-
{q.answer.meaning}
- - ) : ( - <> -
這個單字的意思是?
-
{q.answer.word}
- {hasDistinctReading(q.answer) &&
{q.answer.reading}
} - +
{PROMPT[`${q.qSide}>${q.aSide}`]}
+
{SIDE_TEXT[q.qSide](q.answer)}
+ {/* furigana hint under a kanji prompt — never when the reading itself is the answer */} + {q.qSide === 'kanji' && q.aSide !== 'reading' && hasDistinctReading(q.answer) && ( +
{q.answer.reading}
+ )} + {q.answer.tags?.[0] && ( +
{q.answer.tags[0]}
)}
@@ -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' ? ( + {opt.meaning} + ) : q.aSide === 'kanji' ? ( <> {opt.word} - {hasDistinctReading(opt) && {opt.reading}} + {/* furigana under kanji options, except when the reading was the question */} + {q.qSide !== 'reading' && hasDistinctReading(opt) && ( + {opt.reading} + )} ) : ( - {opt.meaning} + {opt.reading} )} {answered && isCorrect && } {answered && isPicked && !isCorrect && } diff --git a/src/components/TypingMode.jsx b/src/components/TypingMode.jsx index 93a9f5c..aa98305 100644 --- a/src/components/TypingMode.jsx +++ b/src/components/TypingMode.jsx @@ -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 @@ -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 }) { @@ -127,7 +127,8 @@ export default function TypingMode({ lessons, scope = 'all', count = 20, timed =
這個意思的日文是?
{card.word.meaning}
-
+
+ {card.word.tags?.[0] && {card.word.tags[0]}} {card.target === 'reading' ? '請輸入讀音(假名)' : '請輸入漢字'}
diff --git a/src/lib/text.js b/src/lib/text.js index 30344a2..1f58932 100644 --- a/src/lib/text.js +++ b/src/lib/text.js @@ -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('') + 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 }