TransHub / client /src /components /SyntaxReorderer.tsx
linguabot's picture
Upload folder using huggingface_hub
4f163ba verified
// @ts-nocheck
import React, { useMemo, useRef, useState } from "react";
/**
* EN↔ZH Syntax Reorderer — Native HTML5 Drag & Drop + Roles & Patterns (v2.2.3)
*
* The component helps students understand structural and syntactic differences
* between English and Chinese by segmenting, labeling roles, and reordering.
* (Imported from prototype; minor adjustments for integration only.)
*/
// ------- Utilities -------
const containsHan = (s = "") => /\p{Script=Han}/u.test(s);
const isPunc = (t = "") => /[.,!?;:·…—\-,。?!;:、《》〈〉()“”"'\[\]{}]/.test(t);
function hash6(str) {
let h = 0;
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
return (h >>> 0).toString(36).slice(0, 6);
}
function tokenize(text) {
if (!text || !String(text).trim()) return [];
const rough = String(text).replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
const tokens = [];
const re = /(\p{Script=Han}+)|([A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*)|([^\p{Script=Han}A-Za-z0-9\s])/gu;
for (const chunk of (rough.length ? rough : [String(text).trim()])) {
let m;
while ((m = re.exec(chunk))) {
const [tok] = m;
if (tok.trim() !== "") tokens.push(tok);
}
}
return tokens;
}
// Expand any Han token into individual characters (keeps punctuation intact)
function explodeHanChars(tokens = []) {
const out = [];
for (const t of tokens) {
if (containsHan(t) && !isPunc(t) && t.length > 1) {
for (const ch of t) out.push(ch);
} else {
out.push(t);
}
}
return out;
}
const boundaryCount = (tokens) => Math.max(0, (tokens?.length || 0) - 1);
function groupsFrom(tokens = [], boundaries = []) {
if (!Array.isArray(tokens) || tokens.length === 0) return [];
const groups = [];
let cur = [];
tokens.forEach((t, i) => {
cur.push(t);
const atEnd = i === tokens.length - 1;
if (atEnd || !!boundaries[i]) {
groups.push(cur.join(" "));
cur = [];
}
});
return groups;
}
function boundariesFromPunctuation(tokens = []) {
const count = boundaryCount(tokens);
const out = new Array(count).fill(false);
for (let i = 0; i < count; i++) out[i] = isPunc(tokens[i]);
return out;
}
function splitLiteralChunks(literalText, groups) {
if (!literalText || !literalText.trim()) return new Array(Math.max(0, groups.length)).fill("");
let chunks;
if (literalText.includes("|")) {
chunks = literalText.split("|").map((s) => s.trim()).filter((s) => s !== "");
} else if (literalText.includes("\n")) {
chunks = literalText.split(/\n+/).map((s) => s.trim()).filter((s) => s !== "");
} else {
const words = literalText.trim().split(/\s+/).filter(Boolean);
const sizes = groups.map((g) => g.split(/\s+/).filter(Boolean).length || 1);
const total = sizes.reduce((a, b) => a + b, 0) || Math.max(1, groups.length);
chunks = [];
let cursor = 0;
for (let i = 0; i < groups.length; i++) {
const share = Math.round((sizes[i] / total) * words.length);
const end = i === groups.length - 1 ? words.length : Math.min(words.length, cursor + Math.max(1, share));
chunks.push(words.slice(cursor, end).join(" "));
cursor = end;
}
chunks = chunks.map((s) => (s && s.trim()) || "⋯");
}
return chunks;
}
// Small helper to move item in an array
function moveItem(arr, from, to) {
const a = arr.slice();
const [item] = a.splice(from, 1);
a.splice(to, 0, item);
return a;
}
// ---------- Roles & Colours ----------
const ROLES = [
"TIME", "PLACE", "TOPIC", "SUBJECT", "AUX", "VERB", "OBJECT", "MANNER", "DEGREE", "CAUSE", "RESULT", "REL", "EXIST", "BA", "BEI", "AGENT", "OTHER"
];
const ROLE_LABEL = {
TIME: "Time", PLACE: "Place", TOPIC: "Topic/Stance", SUBJECT: "Subject", AUX: "Auxiliary",
VERB: "Verb", OBJECT: "Object", MANNER: "Manner", DEGREE: "Degree",
CAUSE: "Cause", RESULT: "Result", REL: "的‑clause", EXIST: "Existential(有)", BA: "把", BEI: "被", AGENT: "Agent", OTHER: "Other"
};
const ROLE_COLOUR = {
TIME: "bg-blue-50 border-blue-200 text-blue-700",
PLACE: "bg-teal-50 border-teal-200 text-teal-700",
TOPIC: "bg-amber-50 border-amber-200 text-amber-800",
SUBJECT: "bg-sky-50 border-sky-200 text-sky-800",
VERB: "bg-indigo-50 border-indigo-200 text-indigo-700",
OBJECT: "bg-rose-50 border-rose-200 text-rose-700",
MANNER: "bg-violet-50 border-violet-200 text-violet-700",
DEGREE: "bg-fuchsia-50 border-fuchsia-200 text-fuchsia-700",
CAUSE: "bg-orange-50 border-orange-200 text-orange-700",
RESULT: "bg-lime-50 border-lime-200 text-lime-700",
REL: "bg-emerald-50 border-emerald-200 text-emerald-700",
EXIST: "bg-cyan-50 border-cyan-200 text-cyan-700",
BA: "bg-yellow-50 border-yellow-200 text-yellow-700",
BEI: "bg-stone-50 border-stone-200 text-stone-700",
AGENT: "bg-stone-50 border-stone-200 text-stone-800",
OTHER: "bg-zinc-50 border-zinc-200 text-zinc-700",
};
// Prevent drag initiation from form controls inside draggable cards
const NO_DRAG = {
draggable: false,
onDragStart: (e) => e.preventDefault(),
onMouseDown: (e) => e.stopPropagation(),
};
function RolePill({ role }) {
const cls = ROLE_COLOUR[role] || ROLE_COLOUR.OTHER;
return <span className={`inline-flex items-center px-2 py-0.5 rounded-lg border text-[11px] ${cls}`}>{ROLE_LABEL[role] || role}</span>;
}
function RoleSelector({ value, onChange }) {
return (
<select {...NO_DRAG} className="rounded-lg border border-zinc-300 px-2 py-1 text-xs" value={value} onChange={(e) => onChange(e.target.value)}>
{ROLES.map((r) => (
<option key={r} value={r}>{ROLE_LABEL[r]}</option>
))}
</select>
);
}
function SortableCard({ id, idx, src, lit, role, onEdit, onDragStart, onDragEnter, onDragOver, onDrop, onDragEnd, onKeyMove, isDragOver, highlightRoles }) {
const isHL = highlightRoles?.includes(role);
return (
<div
data-id={id}
draggable
onDragStart={(e) => onDragStart(e, id)
}
onDragEnter={(e) => onDragEnter(e, id)}
onDragOver={(e) => onDragOver(e, id)}
onDrop={(e) => onDrop(e, id)}
onDragEnd={onDragEnd}
className={`bg-white rounded-2xl shadow-sm border p-4 mb-3 hover:shadow-md transition outline-none ${isDragOver ? "ring-2 ring-indigo-400" : "border-zinc-200"} ${isHL ? "ring-2 ring-amber-400" : ""} cursor-grab active:cursor-grabbing`}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "ArrowUp") { e.preventDefault(); onKeyMove(id, -1); }
if (e.key === "ArrowDown") { e.preventDefault(); onKeyMove(id, +1); }
}}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="text-xs uppercase tracking-wider text-zinc-500">Chunk {idx + 1}</div>
<RolePill role={role} />
</div>
<div className="flex items-center gap-2">
<RoleSelector value={role} onChange={(r) => onEdit(id, { role: r })} />
<div className="flex gap-1">
<button className="text-[11px] px-2 py-1 rounded bg-zinc-100 hover:bg-zinc-200" onClick={() => onKeyMove(id, -1)} aria-label="Move up">▲</button>
<button className="text-[11px] px-2 py-1 rounded bg-zinc-100 hover:bg-zinc-200" onClick={() => onKeyMove(id, +1)} aria-label="Move down">▼</button>
</div>
</div>
</div>
{/* Grab bar / affordance */}
<div className="w-full mb-3 rounded-xl border border-indigo-200 bg-indigo-50 text-indigo-700 text-xs px-3 py-2 select-none">
⠿ Drag anywhere on the card background • or use ▲▼ keys
</div>
<div className="grid md:grid-cols-2 gap-3">
<label className="text-sm text-zinc-600">
<span className="block text-xs text-zinc-500 mb-1">Source phrase</span>
<input
{...NO_DRAG}
className="w-full rounded-xl border border-zinc-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={src}
onChange={(e) => onEdit(id, { src: e.target.value })}
/>
</label>
<label className="text-sm text-zinc-600">
<span className="block text-xs text-zinc-500 mb-1">Literal translation</span>
<input
{...NO_DRAG}
className="w-full rounded-xl border border-zinc-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={lit}
onChange={(e) => onEdit(id, { lit: e.target.value })}
/>
</label>
</div>
</div>
);
}
// ------------------------
// Self-tests (expanded)
// ------------------------
function runTokenizerTests() {
const cases = [
{ name: "empty string", input: "", expect: [] },
{ name: "spaces only", input: " \t\n ", expect: [] },
{ name: "simple EN", input: "I like apples.", expect: ["I", "like", "apples", "."] },
{ name: "simple ZH", input: "我喜欢中文。", expect: ["我", "喜欢", "中文", "。"] },
{ name: "ZH+quotes", input: "“你好”,世界!", expect: ["“", "你", "好", "”", ",", "世", "界", "!"] },
{ name: "EN hyphen+apostrophe", input: "state-of-the-art don't fail.", expect: ["state-of-the-art", "don't", "fail", "."] },
{ name: "Emoji and symbols", input: "Hi🙂!", expect: ["Hi", "🙂", "!"] },
{ name: "ZH with spaces", input: "我 爱 你。", expect: ["我", "爱", "你", "。"] },
];
return cases.map((c) => {
const got = tokenize(c.input);
const pass = Array.isArray(got) && got.join("|") === c.expect.join("|");
return { name: c.name, pass, got, expect: c.expect };
});
}
function runBoundaryTests() {
const toks = tokenize("我 喜欢 中文 .");
const expectedCount = Math.max(0, toks.length - 1);
const b1 = boundariesFromPunctuation(toks);
const safeNewTrue = new Array(Math.max(0, toks.length - 1)).fill(true);
const safeNewFalse = new Array(Math.max(0, toks.length - 1)).fill(false);
return [
{ name: "boundary count safe", pass: b1.length === expectedCount, got: b1.length, expect: expectedCount },
{ name: "new true safe", pass: safeNewTrue.length === expectedCount },
{ name: "new false safe", pass: safeNewFalse.length === expectedCount },
];
}
function runLiteralSplitTests() {
const groups = ["It is", "difficult", "for me", "to learn Chinese"];
const t1 = splitLiteralChunks("很难 | 对我来说 | 学习中文", groups);
const t2 = splitLiteralChunks("很难\n对我来说\n学习中文", groups);
const t3 = splitLiteralChunks("很 难 对 我 来 说 学 习 中 文", groups);
return [
{ name: "literal with pipes", pass: Array.isArray(t1) && t1.length === groups.length },
{ name: "literal with newlines", pass: Array.isArray(t2) && t2.length === groups.length },
{ name: "literal with words", pass: Array.isArray(t3) && t3.length === groups.length },
];
}
function runCharExplodeTests() {
const base = ["我喜欢中文", "。"];
const exploded = explodeHanChars(base);
const pass = exploded.join("") === "我喜欢中文。" && exploded.length === 6; // 我 喜 欢 中 文 。
return [{ name: "explode Han into characters", pass, got: exploded }];
}
function runNormalizeBoundaryTests() {
const toks = ["我","喜","欢","中","文","。"];
const b = [true, false, true];
const n = Math.max(0, toks.length - 1);
const out = new Array(n).fill(false);
for (let i = 0; i < Math.min(n, b.length); i++) out[i] = !!b[i];
return [{ name: "normalize boundaries len", pass: out.length === toks.length - 1 }];
}
function SelfTests() {
const tokResults = runTokenizerTests();
const bResults = runBoundaryTests();
const lResults = runLiteralSplitTests();
const xResults = runCharExplodeTests();
const nbResults = runNormalizeBoundaryTests();
const all = [...tokResults, ...bResults, ...lResults, ...xResults, ...nbResults];
const passed = all.filter((r) => r.pass).length;
const total = all.length;
return (
<details className="mt-6">
<summary className="cursor-pointer text-sm text-zinc-600">Self-tests: {passed}/{total} passed (click to view)</summary>
<div className="mt-3 text-xs grid gap-2">
{all.map((r, i) => (
<div key={i} className={`p-2 rounded-lg border ${r.pass ? "border-emerald-300 bg-emerald-50" : "border-rose-300 bg-rose-50"}`}>
<div className="font-medium">{r.name} — {r.pass ? "PASS" : "FAIL"}</div>
{r.expect !== undefined && (
<div className="mt-1">
<div><span className="text-zinc-500">expect:</span> {JSON.stringify(r.expect)}</div>
<div><span className="text-zinc-500">got:</span> {JSON.stringify(r.got)}</div>
</div>
)}
</div>
))}
</div>
</details>
);
}
// ---------- Pattern cards data with examples ----------
const PATTERNS = [
{
id: "it_adj",
title: "It is ADJ for SB to VP → 对SB来说 + VP + 很ADJ",
roles: ["TOPIC", "SUBJECT", "VERB", "OBJECT", "DEGREE"],
hint: "Front stance/topic, then VP, then degree+adj (simplified as DEGREE).",
exEn: "It is difficult for me to learn Chinese.",
exZh: "对我来说,学中文很难。",
},
{
id: "rel_clause",
title: "Relative clause → [REL] 的 + N / EN: N + (that/which …)",
roles: ["REL", "OBJECT"],
hint: "ZH: 把后置从句前置为 的‑短语;EN: RC follows the noun.",
exEn: "the book that I bought yesterday is expensive",
exZh: "我昨天买的书很贵。",
},
{
id: "existential",
title: "Existential/there is → (PLACE) + 有 + NP / EN: there is/are + NP (PP)",
roles: ["PLACE", "EXIST", "OBJECT"],
hint: "ZH: 地点可前置,EXIST 代表 ‘有’;EN: ‘there is/are’ + NP (+ place/time).",
exEn: "There is a cat in the room.",
exZh: "房间里有一只猫。",
},
{
id: "ba",
title: "Disposal 把 → 把 + OBJ + V + (RESULT)",
roles: ["BA", "OBJECT", "VERB", "RESULT"],
hint: "突出对宾语的处置;结果补语可选。",
exEn: "I put the keys on the table.",
exZh: "我把钥匙放在桌子上。",
},
{
id: "bei",
title: "Passive 被 → OBJ + 被 + AGENT + V (+RESULT) / EN: AGENT + V + OBJ (or passive)",
roles: ["OBJECT", "BEI", "AGENT", "VERB", "RESULT"],
hint: "ZH: AGENT 明示时放在 被 之后;EN: passive optional; active often preferred.",
exEn: "The door was opened by the wind.",
exZh: "门被风吹开了。",
},
];
const SIMPLE_TITLES = {
it_adj: "Topic → VP → 很+ADJ",
rel_clause: "的‑relative before noun",
existential: "Place + 有 + NP",
ba: "把 disposal pattern",
bei: "被 passive pattern",
} as Record<string, string>;
function ReasoningHints({ direction, cards }) {
if (!cards || cards.length === 0) return null;
const roles = cards.map((c) => c.role);
const hints = [];
const has = (r) => roles.includes(r);
if (direction.startsWith("EN")) {
if (has("TIME") || has("PLACE") || has("TOPIC")) hints.push("Fronted TIME/PLACE/TOPIC reflects Chinese topic/comment tendency.");
if (has("REL") && has("OBJECT")) hints.push("Prenominal 的‑relative clause before the noun (ZH).");
if (has("BA")) hints.push("把‑construction focuses disposal/result on the object.");
if (has("BEI")) hints.push("被‑passive highlights the patient/topic; agent optional.");
if (has("EXIST")) hints.push("Existential 有 introduces new entities: (Place)+有+NP.");
} else {
if (has("REL") && has("OBJECT")) hints.push("English relative clauses follow the noun: N + (that/which …).");
if (has("EXIST")) hints.push("Translate 存现句 with 'there is/are' when introducing new entities.");
if (has("TIME") || has("PLACE")) hints.push("English often places time/place adverbials after the verb phrase or at sentence end.");
}
if (hints.length === 0) return null;
return (
<ul className="list-disc ml-5">{hints.map((h, i) => <li key={i}>{h}</li>)}</ul>
);
}
export default function SyntaxReorderer() {
const [direction, setDirection] = useState("EN → ZH");
const [sourceText, setSourceText] = useState("");
const [literalText, setLiteralText] = useState("");
const prevLiteralRef = useRef("");
const fileRef = useRef(null);
// Character-separator mode (ZH)
const [zhCharMode, setZhCharMode] = useState(false);
const baseTokens = useMemo(() => tokenize(sourceText), [sourceText]);
const hasHanInSource = useMemo(() => /\p{Script=Han}/u.test(sourceText), [sourceText]);
const srcTokens = useMemo(() => (zhCharMode ? explodeHanChars(baseTokens) : baseTokens), [baseTokens, zhCharMode]);
const [boundaries, setBoundaries] = useState([]);
React.useEffect(() => {
setBoundaries((prev) => {
const count = boundaryCount(srcTokens);
const next = new Array(count).fill(false);
for (let i = 0; i < Math.min(prev.length, next.length); i++) next[i] = prev[i];
return next;
});
}, [srcTokens.length]);
const groups = useMemo(() => groupsFrom(srcTokens, boundaries), [srcTokens, boundaries]);
const groupsKey = useMemo(() => groups.join("|"), [groups]);
const [cards, setCards] = useState([]); // {id, src, lit, role}
const [order, setOrder] = useState([]);
const [joiner, setJoiner] = useState("auto");
const [autoBuild, setAutoBuild] = useState(true);
const [hasDragged, setHasDragged] = useState(false);
const [dragId, setDragId] = useState(null);
const [dragOverId, setDragOverId] = useState(null);
const [isDragging, setIsDragging] = useState(false);
// Pattern highlight state
const [highlightRoles, setHighlightRoles] = useState([]);
function rebuild({ preserveByIndex }) {
const litChunks = splitLiteralChunks(literalText, groups);
const built = groups.map((g, i) => ({ id: `${i}-${hash6(g)}`, src: g, lit: litChunks[i] ?? "", role: cards[i]?.role || "OTHER" }));
if (preserveByIndex && cards.length === built.length) {
for (let i = 0; i < built.length; i++) {
if (cards[i]) {
if (typeof cards[i].lit === "string" && cards[i].lit.trim() !== "") built[i].lit = cards[i].lit;
if (cards[i].role) built[i].role = cards[i].role;
}
}
}
setCards(built);
setOrder((prev) => {
if (prev.length === built.length) {
const setIds = new Set(built.map((b) => b.id));
const prevFiltered = prev.filter((id) => setIds.has(id));
if (prevFiltered.length === built.length) return prevFiltered;
}
return built.map((c) => c.id);
});
if (joiner === "auto") {
const anyHan = built.some((c) => containsHan(c.lit));
setJoiner(anyHan ? "none" : "space");
}
}
React.useEffect(() => {
if (!autoBuild || hasDragged) return;
const prevLit = prevLiteralRef.current;
const litChanged = prevLit !== literalText;
rebuild({ preserveByIndex: !litChanged });
prevLiteralRef.current = literalText;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groupsKey, literalText, autoBuild, hasDragged]);
// Helpers for drag guard
function startedOnInteractiveTarget(e) {
const target = e.target as HTMLElement | null;
const tag = (target && target.tagName) || '';
if (["INPUT", "TEXTAREA", "SELECT", "BUTTON"].includes(tag)) return true;
if (target && (target as any).isContentEditable) return true;
return false;
}
// HTML5 DnD handlers
function handleCardDragStart(e, id) {
if (startedOnInteractiveTarget(e)) { e.preventDefault(); return; }
setDragId(id);
setIsDragging(true);
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", String(id)); } catch {}
}
function handleCardDragEnter(e, overId) { if (!isDragging) return; e.preventDefault(); setDragOverId(overId); }
function handleCardDragOver(e, overId) { if (!isDragging) return; e.preventDefault(); setDragOverId(overId); }
function handleCardDrop(e, overId) {
if (!isDragging) return;
e.preventDefault();
const fromId = dragId || e.dataTransfer.getData("text/plain");
if (!fromId || !overId || fromId === overId) { setDragId(null); setDragOverId(null); setIsDragging(false); return; }
setOrder((items) => {
const from = items.indexOf(fromId);
const to = items.indexOf(overId);
if (from === -1 || to === -1) return items;
return moveItem(items, from, to);
});
setHasDragged(true); setDragId(null); setDragOverId(null); setIsDragging(false);
}
function handleCardDragEnd() {
setIsDragging(false);
setDragId(null);
setDragOverId(null);
}
function handleKeyMove(id, delta) {
setOrder((items) => {
const i = items.indexOf(id);
if (i === -1) return items;
let j = i + delta; if (j < 0) j = 0; if (j >= items.length) j = items.length - 1; if (i === j) return items;
return moveItem(items, i, j);
});
setHasDragged(true);
}
function onEditCard(id, patch) { setCards((prev) => prev.map((c) => (c.id === id ? { ...c, ...patch } : c))); }
// Compose outputs
const orderedCards = useMemo(() => order.map((id) => cards.find((c) => c.id === id)).filter(Boolean), [order, cards]);
const composedTarget = useMemo(() => {
const raw = orderedCards.map((c) => c.lit).filter((s) => s != null);
if ((joiner === "none") || (joiner === "auto" && raw.some(containsHan))) return raw.join("");
return raw.join(" ");
}, [orderedCards, joiner]);
// Clear helpers
const clearSource = () => { setSourceText(""); setBoundaries([]); setHasDragged(false); };
const clearLiteral = () => { setLiteralText(""); setHasDragged(false); };
const clearCards = () => { setCards([]); setOrder([]); setHasDragged(false); };
function exportJSON() {
const payload = { direction, sourceText, literalText, tokens: srcTokens, boundaries, groups, cards, order, joiner, composedTarget, timestamp: new Date().toISOString() };
const json = JSON.stringify(payload, null, 2);
try {
const blob = new Blob([json], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "syntax-reorderer-activity.json";
a.rel = "noopener";
document.body.appendChild(a);
a.click();
setTimeout(() => { try { document.body.removeChild(a); } catch {} URL.revokeObjectURL(url); }, 0);
} catch (err) {
// Fallback: copy to clipboard
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(json);
alert("Exported by copying JSON to clipboard.");
} else {
alert("Export failed: " + (err?.message || String(err)));
}
} catch (e2) {
alert("Export failed: " + (err?.message || String(err)));
}
}
}
function normalizeBoundaries(b, tokens) {
const n = Math.max(0, (tokens?.length || 0) - 1);
const out = new Array(n).fill(false);
if (Array.isArray(b)) {
for (let i = 0; i < Math.min(n, b.length); i++) out[i] = !!b[i];
}
return out;
}
function importJSON(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const text = String(e.target?.result || "");
const data = JSON.parse(text);
const src = typeof data.sourceText === "string" ? data.sourceText : "";
const base = tokenize(src);
const chars = explodeHanChars(base);
const wantCharMode = (/\p{Script=Han}/u.test(src)) && Array.isArray(data.tokens) && data.tokens.length === chars.length;
setDirection(typeof data.direction === "string" ? data.direction : direction);
setZhCharMode(wantCharMode);
setSourceText(src);
const toks = wantCharMode ? chars : base;
setBoundaries(normalizeBoundaries(data.boundaries, toks));
setLiteralText(typeof data.literalText === "string" ? data.literalText : "");
setCards(Array.isArray(data.cards) ? data.cards : []);
setOrder(Array.isArray(data.order) ? data.order : []);
setJoiner(data.joiner === "space" || data.joiner === "none" ? data.joiner : "auto");
prevLiteralRef.current = typeof data.literalText === "string" ? data.literalText : "";
setHasDragged(false);
} catch (err) {
alert("Invalid JSON");
}
};
reader.readAsText(file);
}
function loadDemo() {
if (direction.startsWith("EN")) {
// EN → ZH demo
const demoSrc = "It is difficult for me to learn Chinese.";
const demoLit = "很难 | 对我来说 | 学习中文";
setDirection("EN → ZH");
setSourceText(demoSrc);
setBoundaries(boundariesFromPunctuation(tokenize(demoSrc)));
setLiteralText(demoLit);
setHasDragged(false);
prevLiteralRef.current = demoLit;
setZhCharMode(false);
} else {
// ZH → EN demo
const demoSrc = "对我来说,学中文很难。";
const demoLit = "for me | learn Chinese | very difficult";
setDirection("ZH → EN");
setSourceText(demoSrc);
// Default to char separators for Chinese demo
const base = tokenize(demoSrc);
const chars = explodeHanChars(base);
setBoundaries(boundariesFromPunctuation(chars));
setZhCharMode(true);
setLiteralText(demoLit);
setHasDragged(false);
prevLiteralRef.current = demoLit;
}
}
const literalChunks = useMemo(() => splitLiteralChunks(literalText, groups), [literalText, groups]);
// Pattern helpers
function applyPatternOrder(roleSequence) {
setOrder((prev) => {
const seqIndex = new Map(roleSequence.map((r, i) => [r, i]));
const withIdx = prev.map((id, i) => {
const card = cards.find((c) => c.id === id);
const rank = seqIndex.has(card?.role) ? seqIndex.get(card.role) : 999;
return { id, i, rank };
});
withIdx.sort((a, b) => (a.rank - b.rank) || (a.i - b.i));
return withIdx.map((x) => x.id);
});
}
function toggleHighlightRoles(roleSequence) {
setHighlightRoles((prev) => {
const same = prev.length === roleSequence.length && prev.every((r, i) => r === roleSequence[i]);
return same ? [] : roleSequence.slice();
});
}
// ------- Render -------
return (
<div className="min-h-screen bg-zinc-50 text-zinc-900 p-6 md:p-10">
<div className="max-w-6xl mx-auto">
<header className="mb-6">
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight">EN↔ZH Syntax Reorderer</h1>
<p className="text-zinc-600 mt-1">Label chunks, apply patterns, and drag to restructure. Inputs won’t start drags; use the card background.</p>
</header>
{/* Controls */}
<div className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm mb-6">
<div className="flex flex-wrap gap-3 items-center">
<label className="text-sm" title="Target language direction; affects the recipe strip only.">Direction
<select {...NO_DRAG} className="ml-2 rounded-xl border border-zinc-300 px-3 py-2 text-sm" value={direction} onChange={(e) => setDirection(e.target.value)}>
<option>EN → ZH</option>
<option>ZH → EN</option>
</select>
</label>
<label className="text-sm" title="How to join the final literal chunks.">Joiner
<select {...NO_DRAG} className="ml-2 rounded-xl border border-zinc-300 px-3 py-2 text-sm" value={joiner} onChange={(e) => setJoiner(e.target.value)}>
<option value="auto">auto</option>
<option value="space">space</option>
<option value="none">none</option>
</select>
</label>
<div className="ml-auto flex items-center gap-2" title="Auto‑build: when you change separators or the literal list, cards rebuild themselves. It pauses after you drag so your custom order isn’t overwritten.">
<label className="text-sm flex items-center gap-2">
<input type="checkbox" className="scale-110" checked={autoBuild} onChange={(e) => { setAutoBuild(e.target.checked); if (e.target.checked) setHasDragged(false); }} />
Auto‑build
</label>
{hasDragged && autoBuild && (
<button className="px-2 py-1 text-xs rounded-lg bg-indigo-50 hover:bg-indigo-100" onClick={() => setHasDragged(false)} title="Resume auto‑build after a manual drag">Resume</button>
)}
</div>
<button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" title="Force a fresh rebuild from your separators and literal list. Use this if Auto‑build is off." onClick={() => { setHasDragged(false); rebuild({ preserveByIndex: false }); }}>Rebuild now</button>
<button className="px-3 py-2 text-sm rounded-xl bg-rose-100 hover:bg-rose-200" onClick={() => { setSourceText(""); setLiteralText(""); setBoundaries([]); setCards([]); setOrder([]); setHasDragged(false); setHighlightRoles([]); }}>Clear ALL</button>
<button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" onClick={loadDemo}>Load demo</button>
<button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" onClick={exportJSON}>Export JSON</button>
<button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" onClick={() => fileRef.current?.click()}>Import JSON</button>
<input ref={fileRef} type="file" accept="application/json" className="hidden" onChange={(e) => e.target.files?.[0] && importJSON(e.target.files[0])} />
</div>
{/* Recipe strip — switches with direction */}
{direction.startsWith("EN") ? (
<div className="mt-4">
<div className="text-xs text-zinc-500 mb-1">Chinese “light recipe” (typical order):</div>
<div className="flex flex-wrap gap-2">
{["TIME","PLACE","TOPIC","SUBJECT","MANNER","DEGREE","VERB","OBJECT","RESULT"].map((r) => (
<span key={r} className={`px-2 py-1 rounded-lg border text-xs ${ROLE_COLOUR[r]}`}>{ROLE_LABEL[r]}</span>
))}
</div>
</div>
) : (
<div className="mt-4">
<div className="text-xs text-zinc-500 mb-1">English “light recipe” (typical clause order):</div>
<div className="flex flex-wrap gap-2">
{["TIME","SUBJECT","AUX","DEGREE","MANNER","VERB","OBJECT","PLACE","RESULT"].map((r) => (
<span key={r} className={`px-2 py-1 rounded-lg border text-xs ${ROLE_COLOUR[r] || ROLE_COLOUR.OTHER}`}>{r === "AUX" ? "Auxiliary" : (ROLE_LABEL[r] || r)}</span>
))}
</div>
<div className="text-[11px] text-zinc-500 mt-1">Notes: English RCs are post‑nominal ("the book [that I bought]"); many adverbials follow the verb phrase; existential uses “there is/are”.</div>
</div>
)}
</div>
{/* What are roles? */}
<details className="mb-6 bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm">
<summary className="cursor-pointer font-medium">What are “roles” and what do these buttons mean?</summary>
<div className="mt-3 text-sm text-zinc-700 grid gap-2">
<div><strong>Roles</strong> are the <em>function</em> of a chunk (Time, Place, Topic/Stance, Subject, Verb, Object, etc.). Assigning roles lets patterns hint or reorder cards intelligently.</div>
<ul className="list-disc ml-5">
<li><strong>Auto‑build</strong>: when you change separators or the literal list, the cards below update automatically. After you drag manually, it pauses to preserve your custom order.</li>
<li><strong>Rebuild now</strong>: forces an immediate rebuild (useful if Auto‑build is off or paused).</li>
<li><strong>Apply order</strong> (on a pattern): reorders cards so roles that belong to the pattern come first, in the pattern’s typical order. It’s disabled until at least one card has one of those roles.</li>
</ul>
</div>
</details>
{/* Pattern cards (collapsed by default) */}
<details className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm mb-6">
<summary className="cursor-pointer font-medium">Pattern cards</summary>
<div className="mt-3 grid md:grid-cols-2 gap-3">
{PATTERNS.map((p) => {
const matchCount = cards.filter((c) => p.roles.includes(c.role)).length;
return (
<div key={p.id} className="p-3 rounded-xl border border-zinc-200 bg-zinc-50">
<div className="text-sm font-medium flex items-start justify-between gap-2">
<div className="flex-1 min-w-0 pr-2">
<div className="whitespace-normal break-words hyphens-auto">{p.title}</div>
<div className="text-[11px] text-zinc-500 mt-0.5">{SIMPLE_TITLES[p.id] || ''}</div>
</div>
<span className="shrink-0 text-[11px] px-2 py-0.5 rounded-full bg-white border border-zinc-200">matches: {matchCount}</span>
</div>
<div className="text-xs text-zinc-600 mt-1">{p.hint}</div>
<div className="text-xs text-zinc-600 mt-1"><span className="font-medium">Example:</span> {p.exEn} → <span className="text-zinc-800">{p.exZh}</span></div>
<div className="flex flex-wrap gap-2 mt-2">
{p.roles.map((r) => (
<span key={r} className={`px-2 py-0.5 rounded-lg border text-[11px] ${ROLE_COLOUR[r]}`}>{ROLE_LABEL[r]}</span>
))}
</div>
<div className="mt-2 flex gap-2">
<button className="text-xs px-2 py-1 rounded-md bg-zinc-100 hover:bg-zinc-200" onClick={() => toggleHighlightRoles(p.roles)}>Highlight roles</button>
<button disabled={matchCount === 0} className={`text-xs px-2 py-1 rounded-md ${matchCount === 0 ? "bg-zinc-100 text-zinc-400 border border-zinc-200" : "bg-indigo-100 hover:bg-indigo-200"}`} title={matchCount === 0 ? "Set roles on some cards first" : "Reorder cards to reflect this pattern"} onClick={() => applyPatternOrder(p.roles)}>Apply order</button>
</div>
</div>
);
})}
</div>
{highlightRoles.length > 0 && (
<div className="text-xs text-zinc-500 mt-2">Highlighting: {highlightRoles.map((r) => ROLE_LABEL[r]).join(" → ")}</div>
)}
</details>
{/* Sentence + literal inputs */}
<div className="grid md:grid-cols-2 gap-6 mb-6">
<div className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm">
<div className="flex items-center justify-between mb-2">
<h2 className="font-medium">1) Source sentence</h2>
<button className="text-xs px-2 py-1 rounded-md bg-rose-50 hover:bg-rose-100" onClick={clearSource}>Clear source</button>
</div>
<textarea
{...NO_DRAG}
className="w-full min-h-[110px] rounded-xl border border-zinc-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder={direction.startsWith("EN") ? "Paste English here…" : "粘贴中文句子…"}
value={sourceText}
onChange={(e) => setSourceText(e.target.value)}
/>
<div className="mt-2 flex items-center gap-3">
{hasHanInSource && (
<label className="text-xs flex items-center gap-2" title="Split Chinese text by character so you can place a separator between every Han character.">
<input type="checkbox" className="scale-110" checked={zhCharMode} onChange={(e) => {
const checked = e.target.checked; setZhCharMode(checked);
const base = tokenize(sourceText);
const toks = checked ? explodeHanChars(base) : base;
setBoundaries(new Array(Math.max(0, toks.length - 1)).fill(false));
setHasDragged(false);
}} />
Character separators (ZH)
</label>
)}
</div>
<div className="mt-3">
<div className="text-xs text-zinc-500 mb-2">Click separators to toggle phrase boundaries. Cards update below.</div>
<div className="bg-zinc-50 border border-zinc-200 rounded-xl p-3 overflow-x-auto">
{srcTokens.length === 0 ? (
<div className="text-zinc-400 text-sm">Tokens will appear here…</div>
) : (
<div className="flex flex-wrap items-center gap-1">
{srcTokens.map((t, i) => (
<React.Fragment key={i}>
<span className={`px-2 py-1 rounded-lg border ${isPunc(t) ? "border-amber-300 bg-amber-50" : "border-zinc-300 bg-white"}`}>{t}</span>
{i < srcTokens.length - 1 && (
<button
className={`mx-1 px-2 py-1 rounded-full text-xs border ${boundaries[i] ? "bg-indigo-600 text-white border-indigo-600" : "bg-white text-zinc-600 border-zinc-300"}`}
onClick={() => { setBoundaries((prev) => prev.map((b, j) => (j === i ? !b : b))); setHasDragged(false); }}
title="Toggle boundary"
>
|
</button>
)}
</React.Fragment>
))}
</div>
)}
</div>
<div className="mt-3">
<div className="text-xs text-zinc-500 mb-1">Chunk preview ({groups.length}):</div>
<div className="flex flex-wrap gap-2">
{groups.length === 0 ? (
<span className="text-zinc-400 text-sm">(Add boundaries to see chunks)</span>
) : (
groups.map((g, idx) => (
<span key={idx} className="px-2 py-1 rounded-lg border border-zinc-300 bg-white text-sm">{g}</span>
))
)}
</div>
</div>
<div className="flex flex-wrap gap-2 mt-3">
<button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" onClick={() => { setBoundaries(boundariesFromPunctuation(srcTokens)); setHasDragged(false); }}>Auto by punctuation</button>
<button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" onClick={() => { const v = new Array(boundaryCount(srcTokens)).fill(true); setBoundaries(v); setHasDragged(false); }}>Boundary after every token</button>
<button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" onClick={() => { const v = new Array(boundaryCount(srcTokens)).fill(false); setBoundaries(v); setHasDragged(false); }}>Clear boundaries</button>
</div>
</div>
</div>
<div className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm">
<div className="flex items-center justify-between mb-2">
<h2 className="font-medium">2) Literal translation (phrase list)</h2>
<button className="text-xs px-2 py-1 rounded-md bg-rose-50 hover:bg-rose-100" onClick={clearLiteral}>Clear literal</button>
</div>
<textarea
{...NO_DRAG}
className="w-full min-h-[110px] rounded-xl border border-zinc-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder={direction.startsWith("EN") ? "Use | or new lines to separate (e.g., 很难 | 对我来说 | 学习中文)" : "Use | or new lines (e.g., it is difficult | for me | to learn Chinese)"}
value={literalText}
onChange={(e) => { setLiteralText(e.target.value); setHasDragged(false); }}
/>
<div className="text-xs text-zinc-500 mt-2">Literal chunks detected: <strong>{literalChunks.length}</strong>. Source chunks: <strong>{groups.length}</strong>.
{literalChunks.length !== groups.length && <span className="text-rose-600"> — counts don’t match; extra/short chunks will be ignored/padded.</span>}
</div>
</div>
</div>
{/* Drag board */}
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm">
<div className="flex items-center justify-between mb-2">
<h2 className="font-medium">Phrase cards</h2>
<button className="text-xs px-2 py-1 rounded-md bg-rose-50 hover:bg-rose-100" onClick={clearCards}>Clear cards</button>
</div>
{cards.length === 0 ? (
<div className="text-sm text-zinc-500">No cards yet. Add text above.</div>
) : (
order.map((id, idx) => {
const c = cards.find((x) => x.id === id);
if (!c) return null;
return (
<SortableCard
key={id}
id={id}
idx={idx}
src={c.src}
lit={c.lit}
role={c.role || "OTHER"}
onEdit={onEditCard}
onDragStart={handleCardDragStart}
onDragEnter={handleCardDragEnter}
onDragOver={handleCardDragOver}
onDrop={handleCardDrop}
onKeyMove={handleKeyMove}
onDragEnd={handleCardDragEnd}
isDragOver={dragOverId === id}
highlightRoles={highlightRoles}
/>
);
})
)}
</div>
<div className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm">
<h2 className="font-medium mb-2">Result</h2>
<div className="text-xs text-zinc-500 mb-2">Joined literal translation in the new order:</div>
<div className="min-h-[64px] border border-zinc-200 rounded-xl p-3 bg-zinc-50 text-lg leading-8">
{composedTarget || <span className="text-zinc-400">(Will appear here once you reorder)</span>}
</div>
{/* Reasoning hints */}
<div className="mt-3 text-xs text-zinc-600">
<ReasoningHints direction={direction} cards={orderedCards} />
</div>
<div className="mt-4 grid gap-2">
<button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" onClick={() => setOrder(cards.map((c) => c.id))}>Reset order</button>
<div className="text-xs text-zinc-500">Auto‑build is <strong>{autoBuild ? 'ON' : 'OFF'}</strong>{hasDragged ? ' (paused after drag)' : ''}. Use <em>Resume</em> to re‑enable.</div>
</div>
{/* Self-tests intentionally not mounted */}
{/* <SelfTests /> */}
</div>
</div>
</div>
</div>
);
}