|
|
|
|
|
import React, { useMemo, useRef, useState } from "react"; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
function moveItem(arr, from, to) { |
|
|
const a = arr.slice(); |
|
|
const [item] = a.splice(from, 1); |
|
|
a.splice(to, 0, item); |
|
|
return a; |
|
|
} |
|
|
|
|
|
|
|
|
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", |
|
|
}; |
|
|
|
|
|
|
|
|
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> |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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> |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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([]); |
|
|
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); |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
}, [groupsKey, literalText, autoBuild, hasDragged]); |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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))); } |
|
|
|
|
|
|
|
|
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]); |
|
|
|
|
|
|
|
|
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) { |
|
|
|
|
|
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")) { |
|
|
|
|
|
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 { |
|
|
|
|
|
const demoSrc = "对我来说,学中文很难。"; |
|
|
const demoLit = "for me | learn Chinese | very difficult"; |
|
|
setDirection("ZH → EN"); |
|
|
setSourceText(demoSrc); |
|
|
|
|
|
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]); |
|
|
|
|
|
|
|
|
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(); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
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> |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
|