With a phone keyboard up there are about 350px left. Below 840px, focusing
an exercise's answer now strips the lesson back to what is being answered,
as the reworked artifact does: the nav, the header, the roadmap, the quick
replies and the earlier messages go, and an answer bar takes the composer's
place — ✕ · 2 / 4 · 가 · 한 · ↑ ↓ · 제출. Leaving the fields leaves the mode.
The 한글 keyboard follows the field touched last, message box or answer,
each with its own composer, so a half-built syllable stays in the field it
was typed in. The exercise picks the keyboard — up for recall, down for
translate, so one is not left up from the last — until he switches it
himself; that choice holds for the rest of the exercise. With it up, fields
ask for inputmode="none", and a focused field is refocused so the change
takes effect. 가 parks the field and opens the word list over the answer;
closing the list, or Back, returns to the field.
iOS ignores interactive-widget=resizes-content and lets the keyboard cover
the page; there the shell takes the visual viewport's height instead.
Also, from the plan's list:
· a send that gets nothing back takes the message out of the transcript
and puts a typed one back in the box; an exercise keeps its answers.
Stopped with nothing received, the message is withdrawn too. The answer
counts toward the unit once the tutor has it — counted before, a failed
and resent answer counted twice.
· Skip is local: the exercise steps aside and nothing is sent.
· a choice, tapped again, stays chosen.
· Enter moves to the next answer and submits from the last.
· an unreachable server says so, not "Failed to fetch".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
593 lines
19 KiB
TypeScript
593 lines
19 KiB
TypeScript
/* The five exercise types, rendered as real interface.
|
||
|
||
State lives HERE, in the component, keyed by the turn it belongs to. The
|
||
artifact rebuilt taskState inside its full-page re-render, so anything
|
||
arriving mid-answer — a sync snapshot, a re-render — wiped typed text and
|
||
placed chips. Keeping it local is the fix.
|
||
|
||
Nothing is marked client-side. Wrong pairings and wrong chips are accepted
|
||
and sent; 선생님 marks them. That is deliberate: the feedback is the
|
||
lesson, and a client-side ✗ would pre-empt it. */
|
||
|
||
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||
import type {
|
||
BuildTask,
|
||
ChoiceTask,
|
||
MatchTask,
|
||
RecallTask,
|
||
Task,
|
||
TranslateTask,
|
||
WordEntry,
|
||
} from "@lib/blocks.js";
|
||
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 "./task.css";
|
||
|
||
/* A shuffle seed from the turn's id — a string now, see db/ids.ts. */
|
||
function seedOf(id: string): number {
|
||
let h = 2166136261;
|
||
for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 16777619);
|
||
return ((h >>> 0) % 2147483646) + 1;
|
||
}
|
||
|
||
/* A deterministic shuffle, seeded by the turn, so a re-render does not
|
||
reorder the chips under the learner's finger. */
|
||
function shuffle<T>(items: T[], seed: number): T[] {
|
||
const out = [...items];
|
||
let s = seed || 1;
|
||
for (let i = out.length - 1; i > 0; i--) {
|
||
s = (s * 1103515245 + 12345) & 0x7fffffff;
|
||
const j = s % (i + 1);
|
||
[out[i], out[j]] = [out[j]!, out[i]!];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const LABEL: Record<Task["type"], string> = {
|
||
translate: "type what each line means",
|
||
recall: "write it in 한글",
|
||
match: "tap a Korean word, then its meaning",
|
||
build: "tap or drag the words into order",
|
||
choice: "one pick per line",
|
||
};
|
||
|
||
export interface TaskProps {
|
||
task: Task;
|
||
/** The same message's ::words — where a recall answer's spelling comes from. */
|
||
words?: WordEntry[] | null;
|
||
/** Identifies the turn; also seeds the shuffle. */
|
||
turnId: string;
|
||
/** Words the learner revealed in the rail, reported with the answer. */
|
||
lookups: string[];
|
||
onSubmit: (message: string) => void;
|
||
/** Set to this exercise's submit while it is the one open — the answer
|
||
bar's 제출 presses it. */
|
||
submitRef?: RefObject<(() => void) | null>;
|
||
disabled?: boolean;
|
||
/** Already answered: keep the exercise and the answers on screen, but
|
||
show that it is finished rather than the submit controls. */
|
||
spent?: boolean;
|
||
}
|
||
|
||
/* ── the answer field ────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* One answer. It tells the lesson when it has focus, so the 한글 keyboard
|
||
* types into it and answer mode can begin; with that keyboard up it asks
|
||
* the system for none. Enter moves to the next answer, and on the last one
|
||
* submits.
|
||
*/
|
||
function AnswerField({
|
||
id,
|
||
script,
|
||
value,
|
||
update,
|
||
disabled,
|
||
placeholder,
|
||
label,
|
||
className,
|
||
onEnter,
|
||
}: {
|
||
id: string;
|
||
script: KeyField["script"];
|
||
value: string;
|
||
update: (fn: (prev: string) => string) => void;
|
||
disabled: boolean;
|
||
placeholder: string;
|
||
label: string;
|
||
className?: string;
|
||
onEnter: () => void;
|
||
}) {
|
||
const composer = useComposer();
|
||
const fields = useFields();
|
||
const ref = useRef<HTMLInputElement>(null);
|
||
const field: KeyField = { id, kind: "answer", script, apply: update, composer, el: () => ref.current };
|
||
|
||
return (
|
||
<input
|
||
ref={ref}
|
||
type="text"
|
||
data-field={id}
|
||
className={className}
|
||
value={value}
|
||
disabled={disabled}
|
||
placeholder={placeholder}
|
||
aria-label={label}
|
||
autoComplete="off"
|
||
autoCapitalize="off"
|
||
spellCheck={false}
|
||
enterKeyHint="next"
|
||
inputMode={fields.keyboard ? "none" : undefined}
|
||
onFocus={() => fields.onFocus(field)}
|
||
onBlur={() => fields.onBlur(field)}
|
||
onChange={(e) => {
|
||
composer.onExternalInput();
|
||
const v = e.target.value;
|
||
update(() => v);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||
e.preventDefault();
|
||
onEnter();
|
||
}
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
interface FieldsProps {
|
||
turnId: string;
|
||
answers: string[];
|
||
update: (i: number, fn: (prev: string) => string) => void;
|
||
disabled: boolean;
|
||
onEnter: (i: number) => void;
|
||
}
|
||
|
||
/* ── translate ───────────────────────────────────────────────────── */
|
||
|
||
function Translate({ task, turnId, answers, update, disabled, onEnter }: FieldsProps & { task: TranslateTask }) {
|
||
const { answering, activeId } = useFields();
|
||
return (
|
||
<div className="ti">
|
||
{task.items.map((it, i) => {
|
||
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>
|
||
<AnswerField
|
||
id={id}
|
||
script="en"
|
||
value={answers[i] ?? ""}
|
||
update={(fn) => update(i, fn)}
|
||
disabled={disabled}
|
||
placeholder="…"
|
||
label={`Your answer for ${it.q}`}
|
||
onEnter={() => onEnter(i)}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ── recall ──────────────────────────────────────────────────────── */
|
||
|
||
/* English prompt; he writes the 한글, so answer mode brings up the 한글
|
||
keyboard for it. */
|
||
function Recall({ task, turnId, answers, update, disabled, onEnter }: FieldsProps & { task: RecallTask }) {
|
||
const { answering, activeId } = useFields();
|
||
return (
|
||
<div className="ti">
|
||
{task.items.map((it, i) => {
|
||
const id = `${turnId}:${i}`;
|
||
return (
|
||
<div className="ti-row recall" key={i} data-active={answering && activeId === id ? "1" : undefined}>
|
||
<span className="q">
|
||
{it.q}
|
||
{it.hint && <span className="rc-hint"> · {it.hint}</span>}
|
||
</span>
|
||
<AnswerField
|
||
id={id}
|
||
script="ko"
|
||
className="ko"
|
||
value={answers[i] ?? ""}
|
||
update={(fn) => update(i, fn)}
|
||
disabled={disabled}
|
||
placeholder="한국어로…"
|
||
label={`Write ${it.q} in Korean`}
|
||
onEnter={() => onEnter(i)}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ── match ───────────────────────────────────────────────────────── */
|
||
|
||
/*
|
||
Chips are identified by their PAIR INDEX, never by their text.
|
||
|
||
The artifact keyed them by label, so when two items shared an answer —
|
||
"aspirated", "tense" — pairing one disabled every chip with that label,
|
||
and a repeated headword (눈 = eye / snow) broke the left column the same
|
||
way. PORT.md names it as the one UI bug not to re-import.
|
||
*/
|
||
|
||
/** One pairing: indexes into task.pairs for each side. */
|
||
interface Pairing {
|
||
left: number;
|
||
right: number;
|
||
}
|
||
|
||
function Match({ task, turnId, done, setDone, selected, setSelected, disabled }: {
|
||
task: MatchTask;
|
||
turnId: string;
|
||
done: Pairing[];
|
||
setDone: (p: Pairing[]) => void;
|
||
selected: number | null;
|
||
setSelected: (s: number | null) => void;
|
||
disabled: boolean;
|
||
}) {
|
||
const indexes = useMemo(() => task.pairs.map((_, i) => i), [task]);
|
||
const left = useMemo(() => shuffle(indexes, seedOf(turnId)), [indexes, turnId]);
|
||
const right = useMemo(() => shuffle(indexes, seedOf(turnId) + 7), [indexes, turnId]);
|
||
|
||
const usedLeft = new Set(done.map((d) => d.left));
|
||
const usedRight = new Set(done.map((d) => d.right));
|
||
|
||
return (
|
||
<>
|
||
<div className="mt-cols">
|
||
<div className="mt-col">
|
||
{left.map((i) => (
|
||
<button
|
||
key={i}
|
||
className="mt-chip ko"
|
||
data-sel={selected === i ? "1" : undefined}
|
||
disabled={disabled || usedLeft.has(i)}
|
||
onClick={() => setSelected(selected === i ? null : i)}
|
||
>
|
||
{task.pairs[i]!.ko}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="mt-col">
|
||
{right.map((i) => (
|
||
<button
|
||
key={i}
|
||
className="mt-chip"
|
||
disabled={disabled || usedRight.has(i)}
|
||
onClick={() => {
|
||
if (selected === null) return;
|
||
setDone([...done, { left: selected, right: i }]);
|
||
setSelected(null);
|
||
}}
|
||
>
|
||
{task.pairs[i]!.gloss}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{done.length > 0 && (
|
||
<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}
|
||
<button
|
||
aria-label={`Undo ${task.pairs[p.left]!.ko}`}
|
||
disabled={disabled}
|
||
onClick={() => setDone(done.filter((_, j) => j !== i))}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/* Separates the item index from the chip in a drag payload. Written as an
|
||
escape, never as a literal: a raw NUL in the source makes the file binary
|
||
to git, grep and diff, which silently hides this whole component from a
|
||
search. A chip is Korean text and can never contain it. */
|
||
const DRAG_SEP = "\u0000";
|
||
|
||
/* ── build ───────────────────────────────────────────────────────── */
|
||
|
||
function Build({ task, turnId, placed, setPlaced, disabled }: {
|
||
task: BuildTask;
|
||
turnId: string;
|
||
placed: string[][];
|
||
setPlaced: (p: string[][]) => void;
|
||
disabled: boolean;
|
||
}) {
|
||
const banks = useMemo(
|
||
() => task.items.map((it, i) => shuffle(it.chips, seedOf(turnId) + i * 31)),
|
||
[task, turnId],
|
||
);
|
||
|
||
/* A bank slot is used when the copies already placed outnumber the
|
||
identical chips appearing earlier in the bank. That is what lets 밥
|
||
appear twice without both greying out on the first tap. */
|
||
const isUsed = (item: number, chip: string, at: number) => {
|
||
const bank = banks[item] ?? [];
|
||
const earlier = bank.slice(0, at).filter((c) => c === chip).length;
|
||
const used = (placed[item] ?? []).filter((c) => c === chip).length;
|
||
return used > earlier;
|
||
};
|
||
|
||
const place = (item: number, chip: string) => {
|
||
const next = placed.map((p, i) => (i === item ? [...(p ?? []), chip] : p));
|
||
setPlaced(next);
|
||
};
|
||
|
||
const remove = (item: number, at: number) => {
|
||
const next = placed.map((p, i) => (i === item ? (p ?? []).filter((_, j) => j !== at) : p));
|
||
setPlaced(next);
|
||
};
|
||
|
||
return (
|
||
<div className="bd">
|
||
{task.items.map((it, i) => (
|
||
<div className="bd-item" key={i}>
|
||
<div className="bd-en">{it.en}</div>
|
||
<div
|
||
className="bd-slot"
|
||
onDragOver={(e) => e.preventDefault()}
|
||
onDrop={(e) => {
|
||
e.preventDefault();
|
||
const raw = e.dataTransfer.getData("text/plain");
|
||
const [item, chip] = raw.split(DRAG_SEP);
|
||
if (Number(item) === i && chip) place(i, chip);
|
||
}}
|
||
>
|
||
{(placed[i] ?? []).length === 0 && <span className="bd-hint">drag or tap the words</span>}
|
||
{(placed[i] ?? []).map((chip, j) => (
|
||
<button
|
||
key={`${chip}-${j}`}
|
||
className="chip-w ko"
|
||
disabled={disabled}
|
||
onClick={() => remove(i, j)}
|
||
>
|
||
{chip}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="bd-bank">
|
||
{(banks[i] ?? []).map((chip, j) => (
|
||
<button
|
||
key={`${chip}-${j}`}
|
||
className="chip-w ko"
|
||
draggable={!disabled}
|
||
data-used={isUsed(i, chip, j) ? "1" : undefined}
|
||
disabled={disabled || isUsed(i, chip, j)}
|
||
onDragStart={(e) => e.dataTransfer.setData("text/plain", `${i}${DRAG_SEP}${chip}`)}
|
||
onClick={() => place(i, chip)}
|
||
>
|
||
{chip}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ── choice ──────────────────────────────────────────────────────── */
|
||
|
||
const hasHangul = (s: string) => /[가-힣]/.test(s);
|
||
|
||
function Choice({ task, picks, setPicks, disabled }: {
|
||
task: ChoiceTask;
|
||
picks: (number | null)[];
|
||
setPicks: (p: (number | null)[]) => void;
|
||
disabled: boolean;
|
||
}) {
|
||
return (
|
||
<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-opts">
|
||
{it.options.map((opt, j) => (
|
||
<button
|
||
key={j}
|
||
className={hasHangul(opt) ? "ko" : undefined}
|
||
data-sel={picks[i] === j ? "1" : undefined}
|
||
disabled={disabled}
|
||
onClick={() => {
|
||
// Tapping your pick again keeps it. Untoggling on a second
|
||
// tap cleared answers that a double-tap meant to confirm.
|
||
const next = [...picks];
|
||
next[i] = j;
|
||
setPicks(next);
|
||
}}
|
||
>
|
||
{opt}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ── host ────────────────────────────────────────────────────────── */
|
||
|
||
export function TaskHost({
|
||
task,
|
||
words = null,
|
||
turnId,
|
||
lookups,
|
||
onSubmit,
|
||
submitRef,
|
||
disabled = false,
|
||
spent = false,
|
||
}: TaskProps) {
|
||
const [answers, setAnswers] = useState<string[]>([]);
|
||
/* Skip is local, as in the artifact: the exercise steps aside and nothing
|
||
is sent. It used to send "Let's skip that one and just talk", which
|
||
spent a turn — and a round of the tutor's attention — on saying no. */
|
||
const [skipped, setSkipped] = useState(false);
|
||
const root = useRef<HTMLDivElement>(null);
|
||
|
||
const update = (i: number, fn: (prev: string) => string) =>
|
||
setAnswers((prev) => {
|
||
const next = [...prev];
|
||
next[i] = fn(prev[i] ?? "");
|
||
return next;
|
||
});
|
||
const [done, setDone] = useState<Pairing[]>([]);
|
||
const [selected, setSelected] = useState<number | null>(null);
|
||
const [placed, setPlaced] = useState<string[][]>(() =>
|
||
task.type === "build" ? task.items.map(() => []) : [],
|
||
);
|
||
const [picks, setPicks] = useState<(number | null)[]>(() =>
|
||
task.type === "choice" ? task.items.map(() => null) : [],
|
||
);
|
||
|
||
const filled = (() => {
|
||
switch (task.type) {
|
||
case "translate":
|
||
case "recall":
|
||
return { n: answers.filter((a) => a?.trim()).length, of: task.items.length };
|
||
case "match":
|
||
return { n: done.length, of: task.pairs.length };
|
||
case "build":
|
||
return { n: placed.filter((p) => p?.length).length, of: task.items.length };
|
||
case "choice":
|
||
return { n: picks.filter((p) => p != null).length, of: task.items.length };
|
||
}
|
||
})();
|
||
|
||
const submit = () => {
|
||
if (disabled) return;
|
||
const text = (() => {
|
||
switch (task.type) {
|
||
case "translate":
|
||
return answerText(task, answers, lookups);
|
||
case "recall":
|
||
// The jamo comparison, computed here — never left to the tutor.
|
||
return answerText(task, answers, lookups, recallLetterBlock(task, answers, words));
|
||
case "match":
|
||
return answerText(
|
||
task,
|
||
{ pairs: done.map((d) => ({ ko: task.pairs[d.left]!.ko, gloss: task.pairs[d.right]!.gloss })) },
|
||
lookups,
|
||
);
|
||
case "build":
|
||
return answerText(task, placed, lookups);
|
||
case "choice":
|
||
return answerText(task, picks, lookups);
|
||
}
|
||
})();
|
||
onSubmit(text);
|
||
};
|
||
|
||
/* Enter moves to the next answer; on the last one it submits. */
|
||
const onEnter = (i: number) => {
|
||
const fields = root.current?.querySelectorAll<HTMLInputElement>("input[data-field]") ?? [];
|
||
const next = fields[i + 1];
|
||
if (next) next.focus();
|
||
else submit();
|
||
};
|
||
|
||
const open = !spent && !skipped;
|
||
useEffect(() => {
|
||
if (!submitRef || !open) return;
|
||
submitRef.current = submit;
|
||
return () => {
|
||
if (submitRef.current === submit) submitRef.current = null;
|
||
};
|
||
});
|
||
|
||
if (skipped) {
|
||
return (
|
||
<div className="task" data-spent="true">
|
||
<div className="task-spent">exercise skipped — ask for another whenever you like</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="task" data-spent={spent} ref={root}>
|
||
<div className="task-h">
|
||
<span className="eyebrow">연습 · {task.type}</span>
|
||
<span className="hint">{LABEL[task.type]}</span>
|
||
</div>
|
||
|
||
<div className="task-b">
|
||
{task.type === "translate" && (
|
||
<Translate
|
||
task={task}
|
||
turnId={turnId}
|
||
answers={answers}
|
||
update={update}
|
||
disabled={disabled}
|
||
onEnter={onEnter}
|
||
/>
|
||
)}
|
||
{task.type === "recall" && (
|
||
<Recall
|
||
task={task}
|
||
turnId={turnId}
|
||
answers={answers}
|
||
update={update}
|
||
disabled={disabled}
|
||
onEnter={onEnter}
|
||
/>
|
||
)}
|
||
{task.type === "match" && (
|
||
<Match
|
||
task={task}
|
||
turnId={turnId}
|
||
done={done}
|
||
setDone={setDone}
|
||
selected={selected}
|
||
setSelected={setSelected}
|
||
disabled={disabled}
|
||
/>
|
||
)}
|
||
{task.type === "build" && (
|
||
<Build
|
||
task={task}
|
||
turnId={turnId}
|
||
placed={placed}
|
||
setPlaced={setPlaced}
|
||
disabled={disabled}
|
||
/>
|
||
)}
|
||
{task.type === "choice" && (
|
||
<Choice task={task} picks={picks} setPicks={setPicks} disabled={disabled} />
|
||
)}
|
||
</div>
|
||
|
||
{spent ? (
|
||
<div className="task-spent">exercise answered</div>
|
||
) : (
|
||
<div className="task-f">
|
||
<span className="left tnum">
|
||
{filled.n} of {filled.of} filled in
|
||
</span>
|
||
<button className="btn sm" onClick={() => setSkipped(true)} disabled={disabled}>
|
||
Skip
|
||
</button>
|
||
<button className="btn sm primary" onClick={submit} disabled={disabled}>
|
||
Submit answers
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|