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:
MechaCat02
2026-09-16 21:59:48 +02:00
parent 3ba5c293f1
commit ab9b75ff32
13 changed files with 1368 additions and 185 deletions

View File

@@ -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
View 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",
})),
);
}

View File

@@ -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
View 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 || "—",
};
}

View File

@@ -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)} />
</>
);
}

View File

@@ -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>
);
});
}

View File

@@ -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">

View File

@@ -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}>

View 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>
);
}

View File

@@ -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
View 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
View 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();
},
},
};
}