feat(tutor): look any word up — the underline, the popover, the word list
The reworked artifact made every Korean word on the lesson screen a way in to the dictionary, in three tiers. This ports them. 1. The underline. Every Korean run in a message, a gloss or an exercise is a button whose underline says what the word is to him: amber new, blue learning, jade in review, faint when secure or merely explainable, dotted when nothing knows it. A form carries its dictionary word's state — 갔어 is 가다's — through the one resolver the gate uses (domain/words.ts). The artifact gave "in review" no colour at all. 2. The popover, beside the word: the meaning, "+ Add to deck" (the dictionary entry, so 먹었어 adds 먹다), "Ask 선생님" for a word he was shown without being taught, a search, the word list. English on an exercise's English side opens its Korean, from an index of the course's own material — never the frequency bands, whose thousands of glosses would bury the answer. An answer chip's tap belongs to the exercise, so a 420ms hold glosses it, or a tap with 힌트 on; the hold's click is swallowed before the exercise sees it. Pressing a word never takes focus from the answer being typed, and the popover no longer closes when the window resizes — on a phone that was the keyboard moving. 3. The word list gains + / ✓ on every row, search results included. Every lookup, whichever tier, goes into the answer's "I had to look up" and the peek tally — counted under the Korean looked at; the artifact counted an English lookup under the English word. A dictionary word added this way is studied: a card on an entry outside the curated sources now counts as a deck word, in review and in "my units". Before, it got a card that no review would ever show. The word list glosses through the same resolver as the popover and the gate, rather than the surface table alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,8 +53,15 @@ function toCard(row: Record<string, unknown>): Card | null {
|
||||
// every word a unit introduces is a card, because the recall evidence and
|
||||
// the phase review both hang off one.
|
||||
const REVIEWABLE = "('curated', 'sfx', 'grammar', 'custom', 'curriculum')";
|
||||
const REVIEWABLE_SOURCES = new Set(["curated", "sfx", "grammar", "custom", "curriculum"]);
|
||||
const SENTENCE_SOURCE = "('sentence')";
|
||||
|
||||
/* A card on any other dictionary entry is a word he added himself — from a
|
||||
lesson's popover, or the add form when the word was already in the
|
||||
dictionary. Its row stays the dictionary's, so the card is what says it
|
||||
is his. */
|
||||
const ADDED = "(c.lemma_id IS NOT NULL AND l.source <> 'sentence')";
|
||||
|
||||
export interface DeckOptions {
|
||||
/** Mix glossed sentence chunks in, per the session preference. */
|
||||
sentences?: boolean;
|
||||
@@ -75,7 +82,8 @@ export const MY_UNITS_MIN = 20;
|
||||
* them a word added from a lesson was never reviewed again.
|
||||
*/
|
||||
export function isMine(e: Pick<DeckEntry, "source" | "unitId">, p: ProgressState): boolean {
|
||||
if (e.source === "custom") return true;
|
||||
// His own words, and dictionary words he added (see ADDED).
|
||||
if (e.source === "custom" || (!REVIEWABLE_SOURCES.has(e.source) && e.source !== "sentence")) return true;
|
||||
if (!e.unitId) return false;
|
||||
const at = unitIndex(e.unitId);
|
||||
return at >= 0 && (Boolean(p.done[e.unitId]) || at <= unitIndex(p.current));
|
||||
@@ -94,9 +102,8 @@ export function studyPool(entries: DeckEntry[], p: ProgressState): DeckEntry[] {
|
||||
|
||||
function sourceClause(opts: DeckOptions): string {
|
||||
if (opts.only === "sentences") return `l.source IN ${SENTENCE_SOURCE}`;
|
||||
return opts.sentences
|
||||
? `l.source IN ${REVIEWABLE} OR l.source IN ${SENTENCE_SOURCE}`
|
||||
: `l.source IN ${REVIEWABLE}`;
|
||||
const words = `l.source IN ${REVIEWABLE} OR ${ADDED}`;
|
||||
return opts.sentences ? `${words} OR l.source IN ${SENTENCE_SOURCE}` : words;
|
||||
}
|
||||
|
||||
export async function deck(db: Db, opts: DeckOptions = {}): Promise<DeckEntry[]> {
|
||||
|
||||
138
app/src/domain/english.ts
Normal file
138
app/src/domain/english.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/* English → 한국어, for the English side of an exercise.
|
||||
|
||||
Building a sentence from "The cat is on the table" is exactly when he
|
||||
needs to know what "table" is. The artifact indexed its whole lexicon on
|
||||
the English: the gloss whole, each clause of it, and each content word,
|
||||
so "cafe", "older brother" and "to be big" all resolve, and a tap tries
|
||||
the longest phrase around the word first.
|
||||
|
||||
The index holds the deck and the course's own material only — curated
|
||||
words, grammar, sentences, sound words, the curriculum's forms and his
|
||||
own words. Never the frequency bands: thirty thousand dictionary glosses
|
||||
would answer "table" with a dozen words he has never been taught. */
|
||||
|
||||
import type { Db } from "../db/types.js";
|
||||
|
||||
export interface EnEntry {
|
||||
ko: string;
|
||||
gloss: string;
|
||||
note: string;
|
||||
pos: string;
|
||||
/** In his review deck — a sentence chunk is not. */
|
||||
inDeck: boolean;
|
||||
}
|
||||
|
||||
export type EnIndex = Map<string, EnEntry[]>;
|
||||
|
||||
/** The artifact's list: words that are never worth a lookup. */
|
||||
const STOP = new Set([
|
||||
"the", "a", "an", "to", "of", "in", "on", "at", "is", "are", "am", "be", "was", "were",
|
||||
"it", "that", "this", "these", "those", "and", "or", "for", "with", "as", "by", "do", "does",
|
||||
"did", "not", "no", "my", "your", "our", "their", "his", "her", "its", "i", "you", "he", "she",
|
||||
"we", "they", "me", "him", "them", "one", "some", "any", "there", "here", "from", "up", "out",
|
||||
"if", "so", "than", "then", "too", "very", "just", "about", "into", "over", "when", "what",
|
||||
"who", "which", "how", "why", "has", "have", "had", "will", "would", "can", "could", "may",
|
||||
"might", "said", "says", "say", "get", "got", "go", "goes", "s", "t", "re", "ll", "ve",
|
||||
]);
|
||||
|
||||
const PER_KEY = 10;
|
||||
const MAX_HITS = 8;
|
||||
|
||||
const clean = (k: string) =>
|
||||
k
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9' -]/g, "")
|
||||
.trim();
|
||||
|
||||
export function buildEnIndex(entries: EnEntry[]): EnIndex {
|
||||
const index: EnIndex = new Map();
|
||||
const add = (key: string, e: EnEntry) => {
|
||||
const k = clean(key);
|
||||
if (k.length < 2 || STOP.has(k)) return;
|
||||
let list = index.get(k);
|
||||
if (!list) index.set(k, (list = []));
|
||||
if (list.length < PER_KEY && !list.some((x) => x.ko === e.ko)) list.push(e);
|
||||
};
|
||||
|
||||
for (const e of entries) {
|
||||
const gloss = e.gloss.toLowerCase();
|
||||
if (!gloss) continue;
|
||||
add(gloss, e);
|
||||
for (const clause of gloss.split(/[;,/()·]|\s+—\s+|\s+-\s+/)) {
|
||||
// "to be big" is "big"; the artifact's pattern tried "to" before
|
||||
// "to be", which left "be big".
|
||||
const part = clause.trim().replace(/^(to be|to|be)\s+/, "");
|
||||
if (!part) continue;
|
||||
add(part, e);
|
||||
for (const w of part.split(/\s+/)) add(w, e);
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** Whether a word is worth underlining at all. */
|
||||
export const isLookupWord = (word: string): boolean =>
|
||||
word.length >= 2 && !STOP.has(word.toLowerCase());
|
||||
|
||||
/**
|
||||
* What `words[at]` means in Korean: the longest phrase around it first —
|
||||
* "older brother" before "brother" — then a lone plural made singular.
|
||||
*/
|
||||
export function enLookup(index: EnIndex, words: string[], at: number): EnEntry[] {
|
||||
const phrase = (ws: string[]) =>
|
||||
ws
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9' ]/g, "")
|
||||
.replace(/'s\b/g, "")
|
||||
.trim();
|
||||
|
||||
const tries: string[] = [];
|
||||
for (let len = 3; len >= 1; len--) {
|
||||
for (let start = Math.max(0, at - len + 1); start <= at && start + len <= words.length; start++) {
|
||||
tries.push(phrase(words.slice(start, start + len)));
|
||||
}
|
||||
}
|
||||
for (const t of [...tries]) {
|
||||
if (t.includes(" ") || t.length <= 3 || !t.endsWith("s") || t.endsWith("ss")) continue;
|
||||
tries.push(t.slice(0, -1)); // words → word
|
||||
if (t.endsWith("es")) tries.push(t.slice(0, -2)); // boxes → box
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: EnEntry[] = [];
|
||||
for (const t of tries) {
|
||||
for (const e of index.get(t) ?? []) {
|
||||
if (seen.has(e.ko)) continue;
|
||||
seen.add(e.ko);
|
||||
out.push(e);
|
||||
}
|
||||
if (out.length >= MAX_HITS) break;
|
||||
}
|
||||
return out.slice(0, MAX_HITS);
|
||||
}
|
||||
|
||||
const NOTE: Record<string, string> = {
|
||||
custom: "your own word",
|
||||
sentence: "seen in a sentence",
|
||||
sfx: "의성어 · 의태어",
|
||||
};
|
||||
|
||||
export async function loadEnglishIndex(db: Db): Promise<EnIndex> {
|
||||
const rows = await db.all<{ ko: string; gloss: string; pos: string; source: string }>(
|
||||
`SELECT headword AS ko, gloss_en AS gloss, pos, source FROM lemma
|
||||
WHERE source IN ('curated', 'grammar', 'sentence', 'sfx', 'curriculum', 'custom')
|
||||
AND gloss_en <> ''
|
||||
ORDER BY CASE source WHEN 'curated' THEN 0 WHEN 'custom' THEN 1 WHEN 'curriculum' THEN 2
|
||||
WHEN 'grammar' THEN 3 WHEN 'sfx' THEN 4 ELSE 5 END`,
|
||||
);
|
||||
return buildEnIndex(
|
||||
rows.map((r) => ({
|
||||
ko: r.ko,
|
||||
gloss: r.gloss,
|
||||
note: NOTE[r.source] ?? r.pos,
|
||||
pos: r.pos,
|
||||
inDeck: r.source !== "sentence",
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export function lexicon(): Lexicon {
|
||||
const MAX_IN = 900; // Android's bound-parameter ceiling; see db/writes.ts.
|
||||
|
||||
/** Every dictionary headword each form is a surface of, in one pass. */
|
||||
async function dictionaryHeads(db: Db, forms: string[]): Promise<Map<string, string[]>> {
|
||||
export async function dictionaryHeads(db: Db, forms: string[]): Promise<Map<string, string[]>> {
|
||||
const out = new Map<string, string[]>();
|
||||
for (let i = 0; i < forms.length; i += MAX_IN) {
|
||||
const batch = forms.slice(i, i + MAX_IN);
|
||||
|
||||
142
app/src/domain/words.ts
Normal file
142
app/src/domain/words.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/* What a Korean word on the screen is to him.
|
||||
|
||||
Every Korean word in a message, a gloss or an exercise is tappable, and
|
||||
its underline says what it is — the first tier of looking a word up:
|
||||
|
||||
new in his deck, not studied yet
|
||||
learning being learned
|
||||
review in review
|
||||
known secure
|
||||
gloss explainable, but not a word in his deck
|
||||
unknown nothing knows it: a tap offers a search
|
||||
|
||||
A form resolves through the one resolver (resolver.ts): 먹었어 is 먹다's,
|
||||
so it carries 먹다's state. */
|
||||
|
||||
import type { WordEntry } from "@lib/blocks.js";
|
||||
import { statusOf, type Card } from "@lib/srs.js";
|
||||
import type { Db } from "../db/types.js";
|
||||
import { dictionaryHeads, glossMany, lexicon, type Gloss } from "./resolver.js";
|
||||
|
||||
export type WordState = "new" | "learning" | "review" | "known" | "gloss" | "unknown";
|
||||
|
||||
export interface WordInfo {
|
||||
token: string;
|
||||
state: WordState;
|
||||
gloss: Gloss | null;
|
||||
/** The deck word it is, or is a form of. */
|
||||
head: string | null;
|
||||
/** A word he studies, or one the course will teach him. */
|
||||
inDeck: boolean;
|
||||
/** The dictionary entry it resolves to — what "add to deck" adds. */
|
||||
lemma: { headword: string; pos: string; gloss: string } | null;
|
||||
}
|
||||
|
||||
/** A card, or a word the review deck holds. See cards.ts. */
|
||||
const DECK_SOURCES = "('curated', 'grammar', 'sfx', 'curriculum', 'custom')";
|
||||
|
||||
const MAX_IN = 900;
|
||||
|
||||
function batches<T>(items: T[]): T[][] {
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += MAX_IN) out.push(items.slice(i, i + MAX_IN));
|
||||
return out;
|
||||
}
|
||||
|
||||
const place = (n: number) => Array.from({ length: n }, () => "?").join(",");
|
||||
|
||||
export async function wordInfo(
|
||||
db: Db,
|
||||
tokens: Iterable<string>,
|
||||
declared: WordEntry[] | null = null,
|
||||
): Promise<Map<string, WordInfo>> {
|
||||
const forms = [...new Set(tokens)];
|
||||
const out = new Map<string, WordInfo>();
|
||||
if (!forms.length) return out;
|
||||
|
||||
const lex = lexicon();
|
||||
const glosses = await glossMany(db, forms, declared);
|
||||
|
||||
// What each form could be: itself, then every word lexicon.js routes it
|
||||
// to, then — only where lexicon.js has no route — the dictionary's.
|
||||
const candidates = new Map<string, string[]>();
|
||||
const unrouted: string[] = [];
|
||||
for (const f of forms) {
|
||||
const heads = lex.heads(f).filter((h) => h !== f);
|
||||
candidates.set(f, [f, ...heads]);
|
||||
if (!heads.length) unrouted.push(f);
|
||||
}
|
||||
if (unrouted.length) {
|
||||
for (const [form, heads] of await dictionaryHeads(db, unrouted)) {
|
||||
candidates.set(form, [form, ...heads.filter((h) => h !== form)]);
|
||||
}
|
||||
}
|
||||
|
||||
// The deck: the best card per headword, a studied card before an unstudied
|
||||
// entry. Homographs share an underline — 눈 is one word on the screen.
|
||||
const deck = new Map<string, Card | null>();
|
||||
const words = [...new Set([...candidates.values()].flat())];
|
||||
for (const batch of batches(words)) {
|
||||
const rows = await db.all<Card & { headword: string; carded: number | null }>(
|
||||
`SELECT l.headword, c.lemma_id AS carded, c.state, c.ease, c.interval, c.due, c.reps, c.lapses
|
||||
FROM lemma l LEFT JOIN card c ON c.lemma_id = l.id
|
||||
WHERE l.headword IN (${place(batch.length)})
|
||||
AND (l.source IN ${DECK_SOURCES} OR c.lemma_id IS NOT NULL)
|
||||
ORDER BY c.lemma_id IS NULL, c.state = 0, c.interval DESC`,
|
||||
batch,
|
||||
);
|
||||
for (const r of rows) {
|
||||
if (deck.has(r.headword)) continue;
|
||||
deck.set(
|
||||
r.headword,
|
||||
r.carded == null
|
||||
? null
|
||||
: { state: r.state, ease: r.ease, interval: r.interval, due: r.due, reps: r.reps, lapses: r.lapses },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The dictionary entry behind each form, for adding it.
|
||||
const lemmas = new Map<string, WordInfo["lemma"]>();
|
||||
for (const batch of batches(forms)) {
|
||||
const rows = await db.all<{ form: string; headword: string; pos: string; gloss: string }>(
|
||||
`SELECT s.form AS form, l.headword, l.pos, l.gloss_en AS gloss
|
||||
FROM surface s JOIN lemma l ON l.id = s.lemma_id
|
||||
WHERE s.form IN (${place(batch.length)}) AND l.gloss_en <> ''
|
||||
ORDER BY CASE l.source WHEN 'curated' THEN 0 WHEN 'grammar' THEN 1 WHEN 'curriculum' THEN 2
|
||||
WHEN 'custom' THEN 3 WHEN 'sfx' THEN 4 ELSE 5 END,
|
||||
l.freq_rank IS NULL, l.freq_rank`,
|
||||
batch,
|
||||
);
|
||||
for (const r of rows) {
|
||||
if (!lemmas.has(r.form)) lemmas.set(r.form, { headword: r.headword, pos: r.pos, gloss: r.gloss });
|
||||
}
|
||||
}
|
||||
|
||||
for (const f of forms) {
|
||||
const head = candidates.get(f)!.find((c) => deck.has(c)) ?? null;
|
||||
const gloss = glosses.get(f) ?? null;
|
||||
let state: WordState;
|
||||
if (head !== null) {
|
||||
const status = statusOf(deck.get(head));
|
||||
state = status === "secure" ? "known" : status;
|
||||
} else {
|
||||
state = gloss?.gloss ? "gloss" : "unknown";
|
||||
}
|
||||
out.set(f, { token: f, state, gloss, head, inDeck: head !== null, lemma: lemmas.get(f) ?? null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* What adding a looked-up word adds: its dictionary entry when it has one —
|
||||
* 먹었어 adds 먹다 — and otherwise the form as he met it.
|
||||
*/
|
||||
export function wordToAdd(info: WordInfo): { headword: string; pos: string; gloss: string } {
|
||||
if (info.lemma) return info.lemma;
|
||||
return {
|
||||
headword: info.token,
|
||||
pos: /다$/.test(info.token) ? "verb" : "noun",
|
||||
gloss: info.gloss?.gloss || "—",
|
||||
};
|
||||
}
|
||||
@@ -8,18 +8,20 @@
|
||||
import { Fragment } from "react";
|
||||
import type { GlossBlock as Block, GlossPart, GlossRole } from "@lib/blocks.js";
|
||||
import { ROLE_STYLES, isRole, legendFor } from "./roles.js";
|
||||
import { KoText } from "./lookup.js";
|
||||
import "./gloss.css";
|
||||
|
||||
/** Wrap the LAST occurrence of the highlight, which is where a suffix sits. */
|
||||
function withHighlight(ko: string, highlight: string) {
|
||||
if (!highlight) return ko;
|
||||
const at = ko.lastIndexOf(highlight);
|
||||
if (at < 0) return ko;
|
||||
const at = highlight ? ko.lastIndexOf(highlight) : -1;
|
||||
if (at < 0) return <KoText text={ko} />;
|
||||
return (
|
||||
<>
|
||||
{ko.slice(0, at)}
|
||||
<em>{highlight}</em>
|
||||
{ko.slice(at + highlight.length)}
|
||||
<KoText text={ko.slice(0, at)} />
|
||||
<em>
|
||||
<KoText text={highlight} />
|
||||
</em>
|
||||
<KoText text={ko.slice(at + highlight.length)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- a line opening with ✓ or ✗ is a marked answer, and gets the colour. */
|
||||
|
||||
import { Fragment } from "react";
|
||||
import { KoText } from "./lookup.js";
|
||||
|
||||
const KOREAN = /[가-힣]/g;
|
||||
const PUNCT = /[\s.,!?·…"'“”()[\]:;~-]/g;
|
||||
@@ -32,14 +33,27 @@ function isKoreanLine(line: string): boolean {
|
||||
learn:*" kept its asterisks, and a "---" divider showed as three dashes.
|
||||
Degrading gracefully costs little and keeps a lesson readable. */
|
||||
|
||||
/** **bold**, and *italic* as a concession to models that use it. */
|
||||
/** **bold**, and *italic* as a concession to models that use it. Korean
|
||||
in any of it can be looked up. */
|
||||
function inline(text: string) {
|
||||
return text.split(/(\*\*[^*]+\*\*|\*[^*\n]+\*)/g).map((part, i) => {
|
||||
if (part.startsWith("**") && part.endsWith("**") && part.length > 4)
|
||||
return <strong key={i}>{part.slice(2, -2)}</strong>;
|
||||
return (
|
||||
<strong key={i}>
|
||||
<KoText text={part.slice(2, -2)} />
|
||||
</strong>
|
||||
);
|
||||
if (part.startsWith("*") && part.endsWith("*") && part.length > 2)
|
||||
return <em key={i}>{part.slice(1, -1)}</em>;
|
||||
return <Fragment key={i}>{part}</Fragment>;
|
||||
return (
|
||||
<em key={i}>
|
||||
<KoText text={part.slice(1, -1)} />
|
||||
</em>
|
||||
);
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<KoText text={part} />
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { answerText } from "@lib/blocks.js";
|
||||
import { recallLetterBlock } from "../../domain/letters.js";
|
||||
import { useComposer } from "../keyboard/Keyboard.js";
|
||||
import { useFields, type KeyField } from "./fields.js";
|
||||
import { EnText, KoText, useChipGloss } from "./lookup.js";
|
||||
import "./task.css";
|
||||
|
||||
/* A shuffle seed from the turn's id — a string now, see db/ids.ts. */
|
||||
@@ -155,7 +156,9 @@ function Translate({ task, turnId, answers, update, disabled, onEnter }: FieldsP
|
||||
const id = `${turnId}:${i}`;
|
||||
return (
|
||||
<div className="ti-row" key={i} data-active={answering && activeId === id ? "1" : undefined}>
|
||||
<span className="q ko">{it.q}</span>
|
||||
<span className="q ko">
|
||||
<KoText text={it.q} />
|
||||
</span>
|
||||
<AnswerField
|
||||
id={id}
|
||||
script="en"
|
||||
@@ -186,7 +189,7 @@ function Recall({ task, turnId, answers, update, disabled, onEnter }: FieldsProp
|
||||
return (
|
||||
<div className="ti-row recall" key={i} data-active={answering && activeId === id ? "1" : undefined}>
|
||||
<span className="q">
|
||||
{it.q}
|
||||
<EnText text={it.q} />
|
||||
{it.hint && <span className="rc-hint"> · {it.hint}</span>}
|
||||
</span>
|
||||
<AnswerField
|
||||
@@ -278,7 +281,10 @@ function Match({ task, turnId, done, setDone, selected, setSelected, disabled }:
|
||||
<div className="mt-pairs">
|
||||
{done.map((p, i) => (
|
||||
<span className="mt-pair" key={`${p.left}-${p.right}`}>
|
||||
<span className="ko">{task.pairs[p.left]!.ko}</span> = {task.pairs[p.right]!.gloss}
|
||||
<span className="ko">
|
||||
<KoText text={task.pairs[p.left]!.ko} />
|
||||
</span>{" "}
|
||||
= <EnText text={task.pairs[p.right]!.gloss} />
|
||||
<button
|
||||
aria-label={`Undo ${task.pairs[p.left]!.ko}`}
|
||||
disabled={disabled}
|
||||
@@ -338,7 +344,9 @@ function Build({ task, turnId, placed, setPlaced, disabled }: {
|
||||
<div className="bd">
|
||||
{task.items.map((it, i) => (
|
||||
<div className="bd-item" key={i}>
|
||||
<div className="bd-en">{it.en}</div>
|
||||
<div className="bd-en">
|
||||
<EnText text={it.en} />
|
||||
</div>
|
||||
<div
|
||||
className="bd-slot"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
@@ -396,7 +404,9 @@ function Choice({ task, picks, setPicks, disabled }: {
|
||||
<div className="ch">
|
||||
{task.items.map((it, i) => (
|
||||
<div className="ch-item" key={i}>
|
||||
<div className="ch-q ko">{it.q}</div>
|
||||
<div className="ch-q ko">
|
||||
<EnText text={it.q} />
|
||||
</div>
|
||||
<div className="ch-opts">
|
||||
{it.options.map((opt, j) => (
|
||||
<button
|
||||
@@ -440,6 +450,10 @@ export function TaskHost({
|
||||
spent a turn — and a round of the tutor's attention — on saying no. */
|
||||
const [skipped, setSkipped] = useState(false);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
/** 힌트: a tap on a chip glosses it instead of answering. */
|
||||
const [hint, setHint] = useState(false);
|
||||
const chips = useChipGloss(hint);
|
||||
const holdable = task.type === "match" || task.type === "build" || task.type === "choice";
|
||||
|
||||
const update = (i: number, fn: (prev: string) => string) =>
|
||||
setAnswers((prev) => {
|
||||
@@ -520,10 +534,20 @@ export function TaskHost({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="task" data-spent={spent} ref={root}>
|
||||
<div className="task" data-spent={spent} ref={root} {...(holdable && open ? chips.root : {})}>
|
||||
<div className="task-h">
|
||||
<span className="eyebrow">연습 · {task.type}</span>
|
||||
<span className="hint">{LABEL[task.type]}</span>
|
||||
{holdable && open && (
|
||||
<button
|
||||
className="hintbtn ko"
|
||||
aria-pressed={hint}
|
||||
title="Tap a chip to see what it means — or hold it"
|
||||
onClick={() => setHint((h) => !h)}
|
||||
>
|
||||
{hint ? "힌트 켜짐" : "힌트"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="task-b">
|
||||
|
||||
@@ -21,7 +21,15 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { useStore } from "../../state/store.js";
|
||||
import { editChatClear, editChatRemove, editChatTurn, pruneChat, seedChatTurn } from "../../db/writes.js";
|
||||
import {
|
||||
editAddCustomWord,
|
||||
editChatClear,
|
||||
editChatRemove,
|
||||
editChatTurn,
|
||||
editPeek,
|
||||
pruneChat,
|
||||
seedChatTurn,
|
||||
} from "../../db/writes.js";
|
||||
import { parseMessage } from "../../domain/gloss.js";
|
||||
import {
|
||||
gateFor,
|
||||
@@ -36,7 +44,9 @@ import type { FocusMode } from "../../domain/gate.js";
|
||||
import { currentUnit, noteAnswer } from "../../domain/progress.js";
|
||||
import { makeStubTutor, SampleError, type Sample, type StubWord } from "../../domain/stub-tutor.js";
|
||||
import { makeRemoteTutor } from "../../domain/tutor-client.js";
|
||||
import { lookupMany } from "../../domain/lexicon.js";
|
||||
import { koreanTokens, lookupMany } from "../../domain/lexicon.js";
|
||||
import { wordInfo, wordToAdd, type WordInfo } from "../../domain/words.js";
|
||||
import { enLookup, isLookupWord, loadEnglishIndex, type EnIndex } from "../../domain/english.js";
|
||||
import { applyReply, readRecent, runTurn } from "../../domain/turn.js";
|
||||
import { coverage } from "../../domain/ledger.js";
|
||||
import type { Retry } from "../../domain/prompt-tail.js";
|
||||
@@ -52,6 +62,8 @@ import { WordSheet, type Detent } from "./WordSheet.js";
|
||||
import { RoadStrip } from "./RoadStrip.js";
|
||||
import { AnswerBar, Composer } from "./Composer.js";
|
||||
import { FieldsContext, liveAnswerFields, type Fields, type KeyField } from "./fields.js";
|
||||
import { LookupContext, type Lookup } from "./lookup.js";
|
||||
import { EnPop, KoPop, type PopTarget } from "./WordPop.js";
|
||||
import { Keyboard } from "../keyboard/Keyboard.js";
|
||||
import { RouteHead, useRouteActive } from "../shell/Route.js";
|
||||
import { useLayer } from "../shell/router.js";
|
||||
@@ -171,6 +183,14 @@ export function TutorTab() {
|
||||
/** Flagged words per turn id, so the flag stays under its own message. */
|
||||
const [flags, setFlags] = useState<Record<string, string[]>>({});
|
||||
const [roadOpen, setRoadOpen] = useState(false);
|
||||
/** The word popover, and what the word it is open on is to him. */
|
||||
const [pop, setPop] = useState<PopTarget | null>(null);
|
||||
const [popInfo, setPopInfo] = useState<WordInfo | null>(null);
|
||||
/** Every Korean word in the transcript, for its underline. */
|
||||
const [words, setWords] = useState<Map<string, WordInfo>>(new Map());
|
||||
const [enIndex, setEnIndex] = useState<EnIndex | null>(null);
|
||||
/** Added from an English popover this session, for its ✓. */
|
||||
const [addedEn, setAddedEn] = useState<Set<string>>(new Set());
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [detent, setDetent] = useState<Detent>("closed");
|
||||
|
||||
@@ -556,6 +576,109 @@ export function TutorTab() {
|
||||
[turns],
|
||||
);
|
||||
|
||||
/* The first tier: what every Korean word on the screen is to him. Read
|
||||
again when the transcript changes, and whenever cards may have. */
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const tokens = koreanTokens(turns.map((t) => t.body).join("\n"));
|
||||
const declared = parsedTurns.flatMap(({ parsed }) => parsed?.words ?? []);
|
||||
void wordInfo(db, tokens, declared).then((m) => {
|
||||
if (!cancelled) setWords(m);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [db, parsedTurns, revision, turns]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void loadEnglishIndex(db).then((index) => {
|
||||
if (!cancelled) setEnIndex(index);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [db, revision]);
|
||||
|
||||
/* The second tier. Any lookup is a lookup — for the answer's "I had to
|
||||
look up" and for the peek tally, which counts the Korean he looked at:
|
||||
the artifact counted an English lookup under the English word. */
|
||||
const lookedUp = useCallback(
|
||||
(kos: string[]) => {
|
||||
setRevealed((r) => new Set([...r, ...kos]));
|
||||
for (const ko of kos) void editPeek(db, ko);
|
||||
},
|
||||
[db],
|
||||
);
|
||||
|
||||
const openKo = useCallback(
|
||||
(anchor: HTMLElement, token: string) => {
|
||||
setPop({ kind: "ko", token, anchor });
|
||||
lookedUp([token]);
|
||||
},
|
||||
[lookedUp],
|
||||
);
|
||||
|
||||
const openEn = useCallback(
|
||||
(anchor: HTMLElement, word: string, list: string[], at: number) => {
|
||||
const hits = enIndex ? enLookup(enIndex, list, at) : [];
|
||||
if (!hits.length) return;
|
||||
setPop({ kind: "en", word, hits, anchor });
|
||||
lookedUp(hits.map((h) => h.ko));
|
||||
},
|
||||
[enIndex, lookedUp],
|
||||
);
|
||||
|
||||
const lookup = useMemo<Lookup>(
|
||||
() => ({
|
||||
stateOf: (token) => words.get(token)?.state,
|
||||
hasEnglish: (list, at) =>
|
||||
enIndex !== null && isLookupWord(list[at] ?? "") && enLookup(enIndex, list, at).length > 0,
|
||||
openKo,
|
||||
openEn,
|
||||
}),
|
||||
[enIndex, openEn, openKo, words],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (pop?.kind !== "ko") {
|
||||
setPopInfo(null);
|
||||
return;
|
||||
}
|
||||
const known = words.get(pop.token);
|
||||
if (known) {
|
||||
setPopInfo(known);
|
||||
return;
|
||||
}
|
||||
// A chip or a word the transcript's scan did not reach.
|
||||
let cancelled = false;
|
||||
setPopInfo(null);
|
||||
void wordInfo(db, [pop.token]).then((m) => {
|
||||
if (!cancelled) setPopInfo(m.get(pop.token) ?? null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [db, pop, words]);
|
||||
|
||||
// The word it is open on stays highlighted while it is.
|
||||
useEffect(() => {
|
||||
const el = pop?.anchor;
|
||||
if (!el) return;
|
||||
el.dataset.open = "1";
|
||||
return () => {
|
||||
delete el.dataset.open;
|
||||
};
|
||||
}, [pop]);
|
||||
|
||||
const addWord = useCallback(
|
||||
async (word: { headword: string; pos: string; gloss: string }) => {
|
||||
await editAddCustomWord(db, word);
|
||||
invalidate();
|
||||
},
|
||||
[db, invalidate],
|
||||
);
|
||||
|
||||
/* parseMessage() re-parsed the entire partial reply on every token, and
|
||||
on every unrelated re-render of the lesson. */
|
||||
const streamingBody = useMemo(() => {
|
||||
@@ -717,6 +840,16 @@ export function TutorTab() {
|
||||
setDetent("half");
|
||||
};
|
||||
|
||||
/** The popover's "Word list": the column is already there on a wide screen. */
|
||||
const openList = () => {
|
||||
if (wide) return;
|
||||
if (answering) {
|
||||
if (detent === "closed") toggleWords();
|
||||
return;
|
||||
}
|
||||
setDetent((d) => (d === "closed" || d === "peek" ? "half" : d));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (detent !== "closed" || !keepAnswering.current) return;
|
||||
const field = resumeField.current;
|
||||
@@ -770,6 +903,8 @@ export function TutorTab() {
|
||||
onReveal: reveal,
|
||||
query: railQuery,
|
||||
onQuery: setRailQuery,
|
||||
infoOf: (ko: string) => words.get(ko),
|
||||
onAdd: addWord,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -856,167 +991,216 @@ export function TutorTab() {
|
||||
</div>
|
||||
</Pop>
|
||||
|
||||
<FieldsContext.Provider value={fields}>
|
||||
<div className="lesson-wrap">
|
||||
<div
|
||||
className="chatcol"
|
||||
onFocusCapture={(e) => {
|
||||
// Typing an answer brings the sheet down to peek: what is being
|
||||
// answered is never behind it.
|
||||
const t = e.target as HTMLElement;
|
||||
if (keepAnswering.current) return;
|
||||
if ((detent === "half" || detent === "full") && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) {
|
||||
setDetent("peek");
|
||||
}
|
||||
<Pop
|
||||
id="word"
|
||||
open={pop !== null && active}
|
||||
onClose={() => setPop(null)}
|
||||
anchor={pop?.anchor ?? null}
|
||||
label={pop?.kind === "en" ? "English to Korean" : "Word"}
|
||||
>
|
||||
{pop?.kind === "ko" && (
|
||||
<KoPop
|
||||
token={pop.token}
|
||||
info={popInfo}
|
||||
stray={strays.includes(pop.token)}
|
||||
onAdd={() => {
|
||||
if (popInfo) void addWord(wordToAdd(popInfo));
|
||||
setPop(null);
|
||||
}}
|
||||
onInputCapture={() => {
|
||||
if (answering) recount();
|
||||
onAsk={() => {
|
||||
setPop(null);
|
||||
void send(
|
||||
`You used ${pop.token} in that exercise, but you never taught it and it is not in my word list. What does it mean — and was it meant to be there at all?`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<RoadStrip
|
||||
open={roadOpen}
|
||||
onClose={() => setRoadOpen(false)}
|
||||
busy={busy}
|
||||
onUnitChange={(id, how) => {
|
||||
opened.current = true;
|
||||
const u = UNITS.find((x) => x.id === id);
|
||||
if (!u) return;
|
||||
void send(
|
||||
how === "advance"
|
||||
? `좋아 — I'm ready. Let's start unit ${u.id} ${u.ko} (${u.name}). Introduce it in a sentence or two and give me a first exercise.`
|
||||
: `Let's work on unit ${u.id} ${u.ko} (${u.name}). Give me an exercise for it.`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
onFind={() => {
|
||||
setRailQuery(pop.token);
|
||||
setPop(null);
|
||||
openList();
|
||||
}}
|
||||
onList={() => {
|
||||
setRailQuery("");
|
||||
setPop(null);
|
||||
openList();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{pop?.kind === "en" && (
|
||||
<EnPop
|
||||
word={pop.word}
|
||||
hits={pop.hits}
|
||||
added={addedEn}
|
||||
onAdd={(e) => {
|
||||
setAddedEn((a) => new Set(a).add(e.ko));
|
||||
void addWord({ headword: e.ko, pos: e.pos, gloss: e.gloss });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Pop>
|
||||
|
||||
<div className="chat-log" ref={log} onScroll={onLogScroll}>
|
||||
{parsedTurns.map(({ turn: t, parsed }, i) => {
|
||||
const you = t.role === "user";
|
||||
const body = parsed ? parsed.body : ownWords(t.body);
|
||||
return (
|
||||
<div className={`msg${you ? " you" : ""}`} key={t.id}>
|
||||
<span className="who ko">{you ? "나" : "선생님"}</span>
|
||||
{/* A turn can be nothing but blocks — some models write no
|
||||
prose around an exercise at all. Rendering the bubble
|
||||
anyway left an empty box above it. */}
|
||||
{(body.trim() || parsed?.gloss) && (
|
||||
<LookupContext.Provider value={lookup}>
|
||||
<FieldsContext.Provider value={fields}>
|
||||
<div className="lesson-wrap">
|
||||
<div
|
||||
className="chatcol"
|
||||
onFocusCapture={(e) => {
|
||||
// Typing an answer brings the sheet down to peek: what is being
|
||||
// answered is never behind it.
|
||||
const t = e.target as HTMLElement;
|
||||
if (keepAnswering.current) return;
|
||||
if ((detent === "half" || detent === "full") && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) {
|
||||
setDetent("peek");
|
||||
}
|
||||
}}
|
||||
onInputCapture={() => {
|
||||
if (answering) recount();
|
||||
}}
|
||||
>
|
||||
<RoadStrip
|
||||
open={roadOpen}
|
||||
onClose={() => setRoadOpen(false)}
|
||||
busy={busy}
|
||||
onUnitChange={(id, how) => {
|
||||
opened.current = true;
|
||||
const u = UNITS.find((x) => x.id === id);
|
||||
if (!u) return;
|
||||
void send(
|
||||
how === "advance"
|
||||
? `좋아 — I'm ready. Let's start unit ${u.id} ${u.ko} (${u.name}). Introduce it in a sentence or two and give me a first exercise.`
|
||||
: `Let's work on unit ${u.id} ${u.ko} (${u.name}). Give me an exercise for it.`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="chat-log" ref={log} onScroll={onLogScroll}>
|
||||
{parsedTurns.map(({ turn: t, parsed }, i) => {
|
||||
const you = t.role === "user";
|
||||
const body = parsed ? parsed.body : ownWords(t.body);
|
||||
return (
|
||||
<div className={`msg${you ? " you" : ""}`} key={t.id}>
|
||||
<span className="who ko">{you ? "나" : "선생님"}</span>
|
||||
{/* A turn can be nothing but blocks — some models write no
|
||||
prose around an exercise at all. Rendering the bubble
|
||||
anyway left an empty box above it. */}
|
||||
{(body.trim() || parsed?.gloss) && (
|
||||
<div className="bubble">
|
||||
<MessageBody text={body} />
|
||||
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{flags[t.id] && (
|
||||
<p className="msg-flag">
|
||||
Not taught yet: <span className="ko">{flags[t.id]!.join(" · ")}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* An answered exercise stays rendered, read-only. It used
|
||||
to collapse to a single line, which threw away what the
|
||||
learner had typed and dropped ~170px out of the log the
|
||||
instant they pressed Send — the largest single jump in
|
||||
the whole view. */}
|
||||
{parsed?.task && (
|
||||
<TaskHost
|
||||
task={parsed.task}
|
||||
words={parsed.words}
|
||||
turnId={t.id}
|
||||
lookups={[...revealed]}
|
||||
disabled={busy || !isLast(i)}
|
||||
spent={!isLast(i)}
|
||||
onSubmit={(message) => void send(message, { lookups: [...revealed] })}
|
||||
submitRef={isLast(i) ? submitTask : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* One slot for the turn in flight, keyed so the node survives
|
||||
the change from waiting to streaming. As two sibling
|
||||
conditionals the dots unmounted and the text mounted in
|
||||
their place, which read as a blink at the moment the first
|
||||
token arrived. */}
|
||||
{busy && (
|
||||
<div className="msg" key="pending">
|
||||
<span className="who ko">선생님</span>
|
||||
{/* Keep the dots up while the reply so far is only block
|
||||
markup: there is genuinely nothing to read yet, and an
|
||||
empty bubble reads as a failure rather than as waiting. */}
|
||||
{streamingBody.trim() === "" ? (
|
||||
<div className="bubble dots" aria-label="선생님 is writing">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bubble">
|
||||
<MessageBody text={body} />
|
||||
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
|
||||
<MessageBody text={streamingBody} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{flags[t.id] && (
|
||||
<p className="msg-flag">
|
||||
Not taught yet: <span className="ko">{flags[t.id]!.join(" · ")}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* An answered exercise stays rendered, read-only. It used
|
||||
to collapse to a single line, which threw away what the
|
||||
learner had typed and dropped ~170px out of the log the
|
||||
instant they pressed Send — the largest single jump in
|
||||
the whole view. */}
|
||||
{parsed?.task && (
|
||||
<TaskHost
|
||||
task={parsed.task}
|
||||
words={parsed.words}
|
||||
turnId={t.id}
|
||||
lookups={[...revealed]}
|
||||
disabled={busy || !isLast(i)}
|
||||
spent={!isLast(i)}
|
||||
onSubmit={(message) => void send(message, { lookups: [...revealed] })}
|
||||
submitRef={isLast(i) ? submitTask : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* One slot for the turn in flight, keyed so the node survives
|
||||
the change from waiting to streaming. As two sibling
|
||||
conditionals the dots unmounted and the text mounted in
|
||||
their place, which read as a blink at the moment the first
|
||||
token arrived. */}
|
||||
{busy && (
|
||||
<div className="msg" key="pending">
|
||||
<span className="who ko">선생님</span>
|
||||
{/* Keep the dots up while the reply so far is only block
|
||||
markup: there is genuinely nothing to read yet, and an
|
||||
empty bubble reads as a failure rather than as waiting. */}
|
||||
{streamingBody.trim() === "" ? (
|
||||
<div className="bubble dots" aria-label="선생님 is writing">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bubble">
|
||||
<MessageBody text={streamingBody} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Composer
|
||||
unit={unit}
|
||||
busy={busy}
|
||||
notStarted={notStarted}
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
keyboard={keyboard}
|
||||
onToggleKeyboard={() => toggleKeyboard(false)}
|
||||
messageField={messageField}
|
||||
note={note}
|
||||
onSend={(text) => void send(text, { restore: true })}
|
||||
onStop={() => abort.current?.abort()}
|
||||
>
|
||||
{answering && (
|
||||
<AnswerBar
|
||||
position={position}
|
||||
keyboard={keyboard}
|
||||
wordsOpen={detent !== "closed"}
|
||||
canPrev={onField && at > 0}
|
||||
canNext={onField && at < answerFields.length - 1}
|
||||
busy={busy}
|
||||
onDone={() => {
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
exitAnswering();
|
||||
}}
|
||||
onWords={toggleWords}
|
||||
onKeyboard={() => toggleKeyboard(true)}
|
||||
onPrev={() => step(-1)}
|
||||
onNext={() => step(1)}
|
||||
onSubmit={() => {
|
||||
exitAnswering();
|
||||
if (!kbManual.current) setKeyboard(false);
|
||||
submitTask.current?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{keyboard && target && !parked && (
|
||||
<Keyboard
|
||||
composer={target.composer}
|
||||
onChange={target.apply}
|
||||
target={target.kind === "message" ? "message" : "answer"}
|
||||
onKey={() => {
|
||||
const el = target.el();
|
||||
if (el && document.activeElement !== el) el.focus({ preventScroll: true });
|
||||
}}
|
||||
onDismiss={() => setKeyboard(false)}
|
||||
/>
|
||||
)}
|
||||
</Composer>
|
||||
</div>
|
||||
|
||||
<Composer
|
||||
unit={unit}
|
||||
busy={busy}
|
||||
notStarted={notStarted}
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
keyboard={keyboard}
|
||||
onToggleKeyboard={() => toggleKeyboard(false)}
|
||||
messageField={messageField}
|
||||
note={note}
|
||||
onSend={(text) => void send(text, { restore: true })}
|
||||
onStop={() => abort.current?.abort()}
|
||||
>
|
||||
{answering && (
|
||||
<AnswerBar
|
||||
position={position}
|
||||
keyboard={keyboard}
|
||||
wordsOpen={detent !== "closed"}
|
||||
canPrev={onField && at > 0}
|
||||
canNext={onField && at < answerFields.length - 1}
|
||||
busy={busy}
|
||||
onDone={() => {
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
exitAnswering();
|
||||
}}
|
||||
onWords={toggleWords}
|
||||
onKeyboard={() => toggleKeyboard(true)}
|
||||
onPrev={() => step(-1)}
|
||||
onNext={() => step(1)}
|
||||
onSubmit={() => {
|
||||
exitAnswering();
|
||||
if (!kbManual.current) setKeyboard(false);
|
||||
submitTask.current?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{keyboard && target && !parked && (
|
||||
<Keyboard
|
||||
composer={target.composer}
|
||||
onChange={target.apply}
|
||||
target={target.kind === "message" ? "message" : "answer"}
|
||||
onKey={() => {
|
||||
const el = target.el();
|
||||
if (el && document.activeElement !== el) el.focus({ preventScroll: true });
|
||||
}}
|
||||
onDismiss={() => setKeyboard(false)}
|
||||
/>
|
||||
)}
|
||||
</Composer>
|
||||
{wide && (
|
||||
<aside className="railcol" aria-label="Word list">
|
||||
<RailPanel {...railProps} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{wide && (
|
||||
<aside className="railcol" aria-label="Word list">
|
||||
<RailPanel {...railProps} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</FieldsContext.Provider>
|
||||
</FieldsContext.Provider>
|
||||
</LookupContext.Provider>
|
||||
|
||||
{!wide && active && (
|
||||
<WordSheet detent={detent} onDetent={setDetent}>
|
||||
|
||||
121
app/src/ui/tutor/WordPop.tsx
Normal file
121
app/src/ui/tutor/WordPop.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/* The popover: one word, what it means, and what to do about it. */
|
||||
|
||||
import type { WordInfo } from "../../domain/words.js";
|
||||
import type { EnEntry } from "../../domain/english.js";
|
||||
|
||||
export type PopTarget =
|
||||
| { kind: "ko"; token: string; anchor: HTMLElement }
|
||||
| { kind: "en"; word: string; hits: EnEntry[]; anchor: HTMLElement };
|
||||
|
||||
/** Its buttons must not take focus from an answer being typed either. */
|
||||
const keepFocus = (e: { preventDefault: () => void }) => e.preventDefault();
|
||||
|
||||
export function KoPop({
|
||||
token,
|
||||
info,
|
||||
stray,
|
||||
onAdd,
|
||||
onAsk,
|
||||
onFind,
|
||||
onList,
|
||||
}: {
|
||||
token: string;
|
||||
/** Null while it is being looked up. */
|
||||
info: WordInfo | null;
|
||||
/** 선생님 used it in the last exercise without having taught it. */
|
||||
stray: boolean;
|
||||
onAdd: () => void;
|
||||
onAsk: () => void;
|
||||
onFind: () => void;
|
||||
onList: () => void;
|
||||
}) {
|
||||
const gloss = info?.gloss?.gloss;
|
||||
const notes = [
|
||||
info?.gloss?.note,
|
||||
info?.head && info.head !== token && !info.gloss?.note.includes(info.head) ? `a form of ${info.head}` : "",
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div onPointerDown={keepFocus}>
|
||||
<div className="pop-b">
|
||||
<span className="pop-k ko">{token}</span>
|
||||
{gloss ? (
|
||||
<span className="pop-m">{gloss}</span>
|
||||
) : (
|
||||
<span className="pop-m dim">
|
||||
{!info
|
||||
? "…"
|
||||
: stray
|
||||
? "선생님 used this without teaching it — ask him"
|
||||
: "not in the word list yet"}
|
||||
</span>
|
||||
)}
|
||||
{notes.length > 0 && <span className="pop-n">{notes.join(" · ")}</span>}
|
||||
</div>
|
||||
<div className="pop-f">
|
||||
{gloss ? (
|
||||
<button className="btn" disabled={!info || info.inDeck} onClick={onAdd}>
|
||||
{info?.inDeck ? "✓ in your deck" : "+ Add to deck"}
|
||||
</button>
|
||||
) : stray ? (
|
||||
<button className="btn" onClick={onAsk}>
|
||||
Ask 선생님
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn" disabled={!info} onClick={onFind}>
|
||||
Search <span className="ko">단어</span>
|
||||
</button>
|
||||
)}
|
||||
<button className="btn" onClick={onList}>
|
||||
Word list
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnPop({
|
||||
word,
|
||||
hits,
|
||||
added,
|
||||
onAdd,
|
||||
}: {
|
||||
word: string;
|
||||
hits: EnEntry[];
|
||||
/** Added from this popover just now. */
|
||||
added: Set<string>;
|
||||
onAdd: (e: EnEntry) => void;
|
||||
}) {
|
||||
return (
|
||||
<div onPointerDown={keepFocus}>
|
||||
<div className="pop-b">
|
||||
<span className="pop-n">English → 한국어</span>
|
||||
<span className="pop-en">{word}</span>
|
||||
</div>
|
||||
<div className="pop-list">
|
||||
{hits.map((e) => {
|
||||
const inDeck = e.inDeck || added.has(e.ko);
|
||||
return (
|
||||
<div className="pop-row" key={e.ko}>
|
||||
<span className="k ko">{e.ko}</span>
|
||||
<span className="m">
|
||||
{e.gloss}
|
||||
{e.note && <i>{e.note}</i>}
|
||||
</span>
|
||||
<button
|
||||
className="wr-add"
|
||||
data-in={inDeck ? "1" : undefined}
|
||||
disabled={inDeck}
|
||||
aria-label={inDeck ? `${e.ko} is in your deck` : `Add ${e.ko} to your deck`}
|
||||
title={inDeck ? "in your deck" : "add to your deck"}
|
||||
onClick={() => onAdd(e)}
|
||||
>
|
||||
{inDeck ? "✓" : "+"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,8 +23,10 @@ import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { WordEntry } from "@lib/blocks.js";
|
||||
import { useStore } from "../../state/store.js";
|
||||
import { editPeek } from "../../db/writes.js";
|
||||
import { koreanTokens, lookupMany, search, type Entry } from "../../domain/lexicon.js";
|
||||
import { koreanTokens, search, type Entry } from "../../domain/lexicon.js";
|
||||
import { ensureReferenceBand } from "../../domain/dictionary.js";
|
||||
import { glossMany } from "../../domain/resolver.js";
|
||||
import type { WordInfo } from "../../domain/words.js";
|
||||
import "./rail.css";
|
||||
|
||||
export interface RailWord {
|
||||
@@ -40,8 +42,8 @@ const SEARCH_LIMIT = 60;
|
||||
|
||||
/**
|
||||
* Merge the tutor's ::words block with a scan of the message. Tutor entries
|
||||
* win; scanned tokens the dictionary cannot gloss are dropped rather than
|
||||
* shown blank.
|
||||
* win; scanned tokens are glossed by the one resolver the popover and the
|
||||
* gate use, and those it cannot gloss are dropped rather than shown blank.
|
||||
*/
|
||||
export async function collectWords(
|
||||
db: ReturnType<typeof useStore>["db"],
|
||||
@@ -59,17 +61,12 @@ export async function collectWords(
|
||||
|
||||
const tokens = koreanTokens(text).filter((t) => !seen.has(t));
|
||||
if (tokens.length) {
|
||||
const found = await lookupMany(db, tokens);
|
||||
const found = await glossMany(db, tokens);
|
||||
for (const t of tokens) {
|
||||
const hit = found.get(t);
|
||||
if (!hit || seen.has(t)) continue;
|
||||
if (!hit?.gloss || seen.has(t)) continue;
|
||||
seen.add(t);
|
||||
out.push({
|
||||
ko: t,
|
||||
gloss: hit.glossEn,
|
||||
note: hit.analysis && hit.headword !== t ? hit.analysis : "",
|
||||
fromTutor: false,
|
||||
});
|
||||
out.push({ ko: t, gloss: hit.gloss, note: hit.note, fromTutor: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,11 +85,48 @@ export interface RailPanelProps {
|
||||
titleId?: string;
|
||||
/** Buttons at the end of the heading row — the sheet's expand and close. */
|
||||
actions?: ReactNode;
|
||||
/** What a word of the lesson is to him — whether it is in his deck. */
|
||||
infoOf: (ko: string) => WordInfo | undefined;
|
||||
onAdd: (word: { headword: string; pos: string; gloss: string }) => void;
|
||||
}
|
||||
|
||||
export function RailPanel({ words, revealed, onReveal, query, onQuery, titleId, actions }: RailPanelProps) {
|
||||
const DECK_SOURCES = new Set(["curated", "grammar", "sfx", "curriculum", "custom"]);
|
||||
|
||||
/** + adds the word to his deck; ✓ says it is there. */
|
||||
function AddButton({ ko, inDeck, onAdd }: { ko: string; inDeck: boolean; onAdd: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className="wr-add"
|
||||
data-in={inDeck ? "1" : undefined}
|
||||
disabled={inDeck}
|
||||
title={inDeck ? "in your deck" : "add to your deck"}
|
||||
aria-label={inDeck ? `${ko} is in your deck` : `Add ${ko} to your deck`}
|
||||
onClick={onAdd}
|
||||
>
|
||||
{inDeck ? "✓" : "+"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function RailPanel({
|
||||
words,
|
||||
revealed,
|
||||
onReveal,
|
||||
query,
|
||||
onQuery,
|
||||
titleId,
|
||||
actions,
|
||||
infoOf,
|
||||
onAdd,
|
||||
}: RailPanelProps) {
|
||||
const { db, prefs, setPref } = useStore();
|
||||
const [results, setResults] = useState<Entry[]>([]);
|
||||
/** Added from this list just now: a search row cannot tell otherwise. */
|
||||
const [added, setAdded] = useState<Set<string>>(new Set());
|
||||
const add = (word: { headword: string; pos: string; gloss: string }) => {
|
||||
setAdded((a) => new Set(a).add(word.headword));
|
||||
onAdd(word);
|
||||
};
|
||||
const searching = query.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -169,6 +203,11 @@ export function RailPanel({ words, revealed, onReveal, query, onQuery, titleId,
|
||||
{r.glossEn}
|
||||
<i>{r.pos}</i>
|
||||
</span>
|
||||
<AddButton
|
||||
ko={r.headword}
|
||||
inDeck={DECK_SOURCES.has(r.source) || added.has(r.headword)}
|
||||
onAdd={() => add({ headword: r.headword, pos: r.pos, gloss: r.glossEn })}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
@@ -199,6 +238,20 @@ export function RailPanel({ words, revealed, onReveal, query, onQuery, titleId,
|
||||
{w.note && <i>{w.note}</i>}
|
||||
</span>
|
||||
)}
|
||||
<AddButton
|
||||
ko={w.ko}
|
||||
inDeck={Boolean(infoOf(w.ko)?.inDeck) || added.has(w.ko)}
|
||||
onAdd={() => {
|
||||
const info = infoOf(w.ko);
|
||||
add(
|
||||
info?.lemma ?? {
|
||||
headword: w.ko,
|
||||
pos: /다$/.test(w.ko) ? "verb" : "noun",
|
||||
gloss: w.gloss,
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
203
app/src/ui/tutor/lookup.css
Normal file
203
app/src/ui/tutor/lookup.css
Normal file
@@ -0,0 +1,203 @@
|
||||
/* Looking a word up: the underlines, the popover's lists, the add button,
|
||||
and 힌트. */
|
||||
|
||||
/* text-decoration follows the font's own metrics. An inset box-shadow sat
|
||||
on top of ㄲ and ㄹ descenders and made them hard to read. */
|
||||
.kw,
|
||||
.kw-en {
|
||||
padding: 0 1px;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
letter-spacing: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration-line: underline;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.kw {
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 0.24em;
|
||||
text-decoration-skip-ink: none;
|
||||
text-decoration-color: var(--line2);
|
||||
}
|
||||
|
||||
.kw[data-s="new"] {
|
||||
text-decoration-color: var(--hwang);
|
||||
}
|
||||
|
||||
.kw[data-s="learning"] {
|
||||
text-decoration-color: var(--r-sub);
|
||||
}
|
||||
|
||||
/* The artifact had no colour for a word in review: it fell back to the
|
||||
quiet grey of a word he does not study. */
|
||||
.kw[data-s="review"] {
|
||||
text-decoration-color: var(--jade);
|
||||
}
|
||||
|
||||
/* Secure, or explainable but not his: present, but quiet. */
|
||||
.kw[data-s="known"],
|
||||
.kw[data-s="gloss"] {
|
||||
text-decoration-thickness: 1px;
|
||||
text-decoration-color: var(--line2);
|
||||
}
|
||||
|
||||
/* Nothing knows it: the tap offers a search. */
|
||||
.kw[data-s="unknown"] {
|
||||
text-decoration-style: dotted;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
/* In a gloss block the role already carries an underline and the meaning
|
||||
is printed beneath, so a state marker would be a second, competing line.
|
||||
Still tappable, just not marked twice. */
|
||||
.gloss .kw {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.kw-en {
|
||||
text-decoration-style: dotted;
|
||||
text-decoration-thickness: 1.5px;
|
||||
text-decoration-color: var(--ink3);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
.kw:active,
|
||||
.kw[data-open="1"],
|
||||
.kw-en:active,
|
||||
.kw-en[data-open="1"] {
|
||||
background: var(--jade-soft);
|
||||
}
|
||||
|
||||
/* ── the popover's content ───────────────────────────────────────── */
|
||||
|
||||
.pop-m.dim {
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.pop-en {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pop-list {
|
||||
max-height: 44vh;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.pop-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.pop-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.pop-row .k {
|
||||
min-width: 64px;
|
||||
font-size: 19px;
|
||||
font-weight: 500;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.pop-row .m {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.pop-row .m i {
|
||||
display: block;
|
||||
font-style: normal;
|
||||
font-size: 11.5px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
/* ── add to the deck ─────────────────────────────────────────────── */
|
||||
|
||||
.wr-add {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
/* A 44px target. */
|
||||
.wr-add::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -6px;
|
||||
}
|
||||
|
||||
.wr-add:hover:not(:disabled) {
|
||||
border-color: var(--jade);
|
||||
color: var(--jade);
|
||||
}
|
||||
|
||||
.wr-add[data-in="1"] {
|
||||
border-color: var(--jade);
|
||||
background: var(--jade-soft);
|
||||
color: var(--jade-ink);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── 힌트 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.hintbtn {
|
||||
flex: none;
|
||||
min-height: 30px;
|
||||
padding: 4px 11px;
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: 14px;
|
||||
background: var(--raise);
|
||||
font-size: 12px;
|
||||
color: var(--ink2);
|
||||
}
|
||||
|
||||
.hintbtn[aria-pressed="true"] {
|
||||
border-color: var(--hwang);
|
||||
background: var(--hwang);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.task-h .hint + .hintbtn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.task-h .hintbtn:first-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.task-h .hint {
|
||||
display: none;
|
||||
}
|
||||
.task-h .hint + .hintbtn {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* A chip being held should not select its text or open a callout. */
|
||||
.mt-chip,
|
||||
.chip-w,
|
||||
.ch-opts button {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
168
app/src/ui/tutor/lookup.tsx
Normal file
168
app/src/ui/tutor/lookup.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/* Looking a word up, in three tiers.
|
||||
|
||||
1. Every Korean word in a message, a gloss or an exercise is a button,
|
||||
and its underline says what it is to him (domain/words.ts) — ambient,
|
||||
at no cost in space.
|
||||
2. Tapping one opens a popover beside it. English on an exercise's
|
||||
English side opens its Korean. An answer chip's tap belongs to the
|
||||
exercise, so a press-and-hold glosses the chip instead — or a single
|
||||
tap, with 힌트 switched on.
|
||||
3. The whole word list, in the sheet or the docked column.
|
||||
|
||||
Every lookup is a lookup: it goes into the answer's "I had to look up"
|
||||
and the peek tally, whichever tier it came through. Pressing a word never
|
||||
takes focus from the answer being typed — that would close the keyboard
|
||||
under his finger. */
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
type MouseEvent,
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { WordState } from "../../domain/words.js";
|
||||
import "./lookup.css";
|
||||
|
||||
export interface Lookup {
|
||||
stateOf: (token: string) => WordState | undefined;
|
||||
/** Whether `words[at]` has a Korean meaning worth offering. */
|
||||
hasEnglish: (words: string[], at: number) => boolean;
|
||||
openKo: (anchor: HTMLElement, token: string) => void;
|
||||
openEn: (anchor: HTMLElement, word: string, words: string[], at: number) => void;
|
||||
}
|
||||
|
||||
export const LookupContext = createContext<Lookup | null>(null);
|
||||
|
||||
/** Pressing a word must not move focus off the field being answered. */
|
||||
const keepFocus = (e: PointerEvent | MouseEvent) => e.preventDefault();
|
||||
|
||||
/** Korean runs in `text` become lookups; the rest stays text. */
|
||||
export function KoText({ text }: { text: string }): ReactNode {
|
||||
const lookup = useContext(LookupContext);
|
||||
if (!lookup || !/[가-힣]/.test(text)) return text;
|
||||
return text.split(/([가-힣]+)/).map((part, i) =>
|
||||
i % 2 ? (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
className="kw"
|
||||
data-s={lookup.stateOf(part)}
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
lookup.openKo(e.currentTarget, part);
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
) : (
|
||||
part
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* English words with a Korean meaning become lookups — only those: an
|
||||
* underline under every English word would be noise. Korean in the same
|
||||
* text is wrapped as KoText does.
|
||||
*/
|
||||
export function EnText({ text }: { text: string }): ReactNode {
|
||||
const lookup = useContext(LookupContext);
|
||||
if (!lookup) return text;
|
||||
const parts = text.split(/([A-Za-z][A-Za-z'-]*)/);
|
||||
const words = parts.filter((_, i) => i % 2 === 1);
|
||||
let at = -1;
|
||||
return parts.map((part, i) => {
|
||||
if (i % 2 === 0) return part ? <KoText key={i} text={part} /> : null;
|
||||
const here = ++at;
|
||||
if (!lookup.hasEnglish(words, here)) return part;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
className="kw-en"
|
||||
onPointerDown={keepFocus}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
lookup.openEn(e.currentTarget, part, words, here);
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const CHIPS = ".mt-chip, .chip-w, .ch-opts button";
|
||||
const HOLD_MS = 420;
|
||||
|
||||
/**
|
||||
* Gloss an exercise's answer chips: a press-and-hold on any of them, or a
|
||||
* tap while `hint` is on. The hold's own click is swallowed in the capture
|
||||
* phase, before the exercise sees it — a gloss must not also answer.
|
||||
*/
|
||||
export function useChipGloss(hint: boolean) {
|
||||
const lookup = useContext(LookupContext);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const swallow = useRef(false);
|
||||
|
||||
const gloss = (chip: HTMLElement) => {
|
||||
const text = (chip.textContent ?? "").trim();
|
||||
if (!text || !lookup) return;
|
||||
const korean = text.match(/[가-힣]+/);
|
||||
if (korean) lookup.openKo(chip, korean[0]);
|
||||
else {
|
||||
const words = text.split(/\s+/);
|
||||
lookup.openEn(chip, words[0]!, words, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
/** Spread on the exercise's root. */
|
||||
root: {
|
||||
onPointerDown: (e: PointerEvent) => {
|
||||
// A hold that never became a click must not swallow the next tap.
|
||||
swallow.current = false;
|
||||
const chip = (e.target as HTMLElement).closest<HTMLButtonElement>(CHIPS);
|
||||
if (!chip || chip.disabled) return;
|
||||
cancel();
|
||||
timer.current = setTimeout(() => {
|
||||
timer.current = undefined;
|
||||
swallow.current = true;
|
||||
navigator.vibrate?.(8);
|
||||
gloss(chip);
|
||||
}, HOLD_MS);
|
||||
},
|
||||
onPointerUp: cancel,
|
||||
onPointerCancel: cancel,
|
||||
onPointerMove: (e: PointerEvent) => {
|
||||
if (timer.current && !(e.target as HTMLElement).closest(CHIPS)) cancel();
|
||||
},
|
||||
onClickCapture: (e: MouseEvent) => {
|
||||
const chip = (e.target as HTMLElement).closest<HTMLElement>(CHIPS);
|
||||
if (!chip) return;
|
||||
if (swallow.current || hint) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!swallow.current) gloss(chip);
|
||||
swallow.current = false;
|
||||
}
|
||||
},
|
||||
onContextMenu: (e: MouseEvent) => {
|
||||
// A long press on Android opens the context menu over the chip.
|
||||
if ((e.target as HTMLElement).closest(CHIPS)) e.preventDefault();
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
127
test/domain/words.test.ts
Normal file
127
test/domain/words.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/* Looking a word up: what its underline says, what adding it adds, and
|
||||
English → 한국어 — against the shipped dictionary. */
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
||||
import type { Db } from "@app/db/types.js";
|
||||
import { editAddCustomWord, editCard } from "@app/db/writes.js";
|
||||
import { wordInfo, wordToAdd } from "@app/domain/words.js";
|
||||
import { buildEnIndex, enLookup, isLookupWord, loadEnglishIndex } from "@app/domain/english.js";
|
||||
import { cardLemmaFor } from "@app/domain/evidence.js";
|
||||
import { deck } from "@app/domain/cards.js";
|
||||
import { SECURE_INTERVAL, newCard } from "@lib/srs.js";
|
||||
import { dictionaryDb } from "../helpers/dict-db.js";
|
||||
|
||||
let db: Db;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await dictionaryDb(1);
|
||||
});
|
||||
afterAll(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
describe("the underline", () => {
|
||||
it("marks a deck word he has not studied as new, and a secure one as known", async () => {
|
||||
let info = (await wordInfo(db, ["바다"])).get("바다")!;
|
||||
expect(info).toMatchObject({ state: "new", inDeck: true, head: "바다" });
|
||||
expect(info.gloss?.gloss).toBeTruthy();
|
||||
|
||||
const id = (await cardLemmaFor(db, "바다"))!;
|
||||
await editCard(db, id, { ...newCard(), state: 2, interval: SECURE_INTERVAL, due: 99_999, reps: 6 });
|
||||
info = (await wordInfo(db, ["바다"])).get("바다")!;
|
||||
expect(info.state).toBe("known");
|
||||
});
|
||||
|
||||
it("gives a form its dictionary word's state", async () => {
|
||||
const id = (await cardLemmaFor(db, "가다"))!;
|
||||
await editCard(db, id, { ...newCard(), state: 1, due: 0, reps: 1 });
|
||||
const info = (await wordInfo(db, ["갔어"])).get("갔어")!;
|
||||
expect(info.state).toBe("learning");
|
||||
expect(info.head).toBe("가다");
|
||||
});
|
||||
|
||||
it("calls a word only the dictionary knows explainable, and nothing at all unknown", async () => {
|
||||
const row = await db.get<{ headword: string }>(
|
||||
`SELECT l.headword FROM lemma l
|
||||
WHERE l.source NOT IN ('curated','grammar','sfx','curriculum','custom','sentence')
|
||||
AND l.gloss_en <> '' AND l.headword NOT IN
|
||||
(SELECT headword FROM lemma WHERE source IN ('curated','grammar','sfx','curriculum','custom'))
|
||||
ORDER BY l.freq_rank LIMIT 1`,
|
||||
);
|
||||
const word = row!.headword;
|
||||
const infos = await wordInfo(db, [word, "뷁뷁"]);
|
||||
expect(infos.get(word)).toMatchObject({ state: "gloss", inDeck: false });
|
||||
expect(infos.get("뷁뷁")).toMatchObject({ state: "unknown", inDeck: false, gloss: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("adding a looked-up word", () => {
|
||||
it("adds the dictionary word, and it then counts as his — in the deck and underlined new", async () => {
|
||||
const row = await db.get<{ headword: string }>(
|
||||
`SELECT l.headword FROM lemma l
|
||||
WHERE l.source NOT IN ('curated','grammar','sfx','curriculum','custom','sentence')
|
||||
AND l.gloss_en <> '' AND l.headword NOT IN
|
||||
(SELECT headword FROM lemma WHERE source IN ('curated','grammar','sfx','curriculum','custom'))
|
||||
ORDER BY l.freq_rank LIMIT 1 OFFSET 3`,
|
||||
);
|
||||
const word = row!.headword;
|
||||
const before = (await wordInfo(db, [word])).get(word)!;
|
||||
const add = wordToAdd(before);
|
||||
expect(add.headword).toBe(word);
|
||||
|
||||
await editAddCustomWord(db, add);
|
||||
const after = (await wordInfo(db, [word])).get(word)!;
|
||||
expect(after).toMatchObject({ state: "new", inDeck: true });
|
||||
expect((await deck(db)).some((e) => e.headword === word)).toBe(true);
|
||||
});
|
||||
|
||||
it("adds a form nothing knows as he met it", () => {
|
||||
expect(
|
||||
wordToAdd({ token: "뷁다", state: "unknown", gloss: null, head: null, inDeck: false, lemma: null }),
|
||||
).toEqual({ headword: "뷁다", pos: "verb", gloss: "—" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("English → 한국어", () => {
|
||||
const index = buildEnIndex([
|
||||
{ ko: "형", gloss: "older brother (said by a man)", note: "noun", pos: "noun", inDeck: true },
|
||||
{ ko: "크다", gloss: "to be big", note: "adj", pos: "adj", inDeck: true },
|
||||
{ ko: "단어", gloss: "word", note: "noun", pos: "noun", inDeck: true },
|
||||
{ ko: "책상", gloss: "desk; table", note: "noun", pos: "noun", inDeck: true },
|
||||
]);
|
||||
|
||||
it("tries the longest phrase around the word first", () => {
|
||||
const words = "my older brother is tall".split(" ");
|
||||
expect(enLookup(index, words, 2).map((e) => e.ko)).toEqual(["형"]);
|
||||
});
|
||||
|
||||
it("finds a word inside a gloss, a clause of one, and a plural made singular", () => {
|
||||
expect(enLookup(index, ["big"], 0).map((e) => e.ko)).toEqual(["크다"]);
|
||||
expect(enLookup(index, ["table"], 0).map((e) => e.ko)).toEqual(["책상"]);
|
||||
expect(enLookup(index, ["the", "words"], 1).map((e) => e.ko)).toEqual(["단어"]);
|
||||
});
|
||||
|
||||
it("never offers the small words", () => {
|
||||
expect(isLookupWord("the")).toBe(false);
|
||||
expect(isLookupWord("a")).toBe(false);
|
||||
expect(isLookupWord("table")).toBe(true);
|
||||
});
|
||||
|
||||
it("is built from the course's own material, never the frequency bands", async () => {
|
||||
const shipped = await loadEnglishIndex(db);
|
||||
expect(enLookup(shipped, ["water"], 0).map((e) => e.ko)).toContain("물");
|
||||
|
||||
const course = new Set(
|
||||
(
|
||||
await db.all<{ headword: string }>(
|
||||
`SELECT headword FROM lemma
|
||||
WHERE source IN ('curated', 'grammar', 'sentence', 'sfx', 'curriculum', 'custom')`,
|
||||
)
|
||||
).map((r) => r.headword),
|
||||
);
|
||||
const indexed = new Set([...shipped.values()].flat().map((e) => e.ko));
|
||||
expect(indexed.size).toBeGreaterThan(500);
|
||||
expect([...indexed].filter((ko) => !course.has(ko))).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user