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
}