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