linguabot commited on
Commit
84c833b
·
verified ·
1 Parent(s): 330126b

Upload client/src/components/SyntaxReorderer.tsx with huggingface_hub

Browse files
client/src/components/SyntaxReorderer.tsx ADDED
@@ -0,0 +1,908 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-nocheck
2
+ import React, { useMemo, useRef, useState } from "react";
3
+
4
+ /**
5
+ * EN↔ZH Syntax Reorderer — Native HTML5 Drag & Drop + Roles & Patterns (v2.2.3)
6
+ *
7
+ * The component helps students understand structural and syntactic differences
8
+ * between English and Chinese by segmenting, labeling roles, and reordering.
9
+ * (Imported from prototype; minor adjustments for integration only.)
10
+ */
11
+
12
+ // ------- Utilities -------
13
+ const containsHan = (s = "") => /\p{Script=Han}/u.test(s);
14
+ const isPunc = (t = "") => /[.,!?;:·…—\-,。?!;:、《》〈〉()“”"'\[\]{}]/.test(t);
15
+
16
+ function hash6(str) {
17
+ let h = 0;
18
+ for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
19
+ return (h >>> 0).toString(36).slice(0, 6);
20
+ }
21
+
22
+ function tokenize(text) {
23
+ if (!text || !String(text).trim()) return [];
24
+ const rough = String(text).replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
25
+ const tokens = [];
26
+ const re = /(\p{Script=Han}+)|([A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*)|([^\p{Script=Han}A-Za-z0-9\s])/gu;
27
+ for (const chunk of (rough.length ? rough : [String(text).trim()])) {
28
+ let m;
29
+ while ((m = re.exec(chunk))) {
30
+ const [tok] = m;
31
+ if (tok.trim() !== "") tokens.push(tok);
32
+ }
33
+ }
34
+ return tokens;
35
+ }
36
+
37
+ // Expand any Han token into individual characters (keeps punctuation intact)
38
+ function explodeHanChars(tokens = []) {
39
+ const out = [];
40
+ for (const t of tokens) {
41
+ if (containsHan(t) && !isPunc(t) && t.length > 1) {
42
+ for (const ch of t) out.push(ch);
43
+ } else {
44
+ out.push(t);
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ const boundaryCount = (tokens) => Math.max(0, (tokens?.length || 0) - 1);
51
+
52
+ function groupsFrom(tokens = [], boundaries = []) {
53
+ if (!Array.isArray(tokens) || tokens.length === 0) return [];
54
+ const groups = [];
55
+ let cur = [];
56
+ tokens.forEach((t, i) => {
57
+ cur.push(t);
58
+ const atEnd = i === tokens.length - 1;
59
+ if (atEnd || !!boundaries[i]) {
60
+ groups.push(cur.join(" "));
61
+ cur = [];
62
+ }
63
+ });
64
+ return groups;
65
+ }
66
+
67
+ function boundariesFromPunctuation(tokens = []) {
68
+ const count = boundaryCount(tokens);
69
+ const out = new Array(count).fill(false);
70
+ for (let i = 0; i < count; i++) out[i] = isPunc(tokens[i]);
71
+ return out;
72
+ }
73
+
74
+ function splitLiteralChunks(literalText, groups) {
75
+ if (!literalText || !literalText.trim()) return new Array(Math.max(0, groups.length)).fill("");
76
+ let chunks;
77
+ if (literalText.includes("|")) {
78
+ chunks = literalText.split("|").map((s) => s.trim()).filter((s) => s !== "");
79
+ } else if (literalText.includes("\n")) {
80
+ chunks = literalText.split(/\n+/).map((s) => s.trim()).filter((s) => s !== "");
81
+ } else {
82
+ const words = literalText.trim().split(/\s+/).filter(Boolean);
83
+ const sizes = groups.map((g) => g.split(/\s+/).filter(Boolean).length || 1);
84
+ const total = sizes.reduce((a, b) => a + b, 0) || Math.max(1, groups.length);
85
+ chunks = [];
86
+ let cursor = 0;
87
+ for (let i = 0; i < groups.length; i++) {
88
+ const share = Math.round((sizes[i] / total) * words.length);
89
+ const end = i === groups.length - 1 ? words.length : Math.min(words.length, cursor + Math.max(1, share));
90
+ chunks.push(words.slice(cursor, end).join(" "));
91
+ cursor = end;
92
+ }
93
+ chunks = chunks.map((s) => (s && s.trim()) || "⋯");
94
+ }
95
+ return chunks;
96
+ }
97
+
98
+ // Small helper to move item in an array
99
+ function moveItem(arr, from, to) {
100
+ const a = arr.slice();
101
+ const [item] = a.splice(from, 1);
102
+ a.splice(to, 0, item);
103
+ return a;
104
+ }
105
+
106
+ // ---------- Roles & Colours ----------
107
+ const ROLES = [
108
+ "TIME", "PLACE", "TOPIC", "SUBJECT", "AUX", "VERB", "OBJECT", "MANNER", "DEGREE", "CAUSE", "RESULT", "REL", "EXIST", "BA", "BEI", "AGENT", "OTHER"
109
+ ];
110
+ const ROLE_LABEL = {
111
+ TIME: "Time", PLACE: "Place", TOPIC: "Topic/Stance", SUBJECT: "Subject", AUX: "Auxiliary",
112
+ VERB: "Verb", OBJECT: "Object", MANNER: "Manner", DEGREE: "Degree",
113
+ CAUSE: "Cause", RESULT: "Result", REL: "的‑clause", EXIST: "Existential(有)", BA: "把", BEI: "被", AGENT: "Agent", OTHER: "Other"
114
+ };
115
+ const ROLE_COLOUR = {
116
+ TIME: "bg-blue-50 border-blue-200 text-blue-700",
117
+ PLACE: "bg-teal-50 border-teal-200 text-teal-700",
118
+ TOPIC: "bg-amber-50 border-amber-200 text-amber-800",
119
+ SUBJECT: "bg-sky-50 border-sky-200 text-sky-800",
120
+ VERB: "bg-indigo-50 border-indigo-200 text-indigo-700",
121
+ OBJECT: "bg-rose-50 border-rose-200 text-rose-700",
122
+ MANNER: "bg-violet-50 border-violet-200 text-violet-700",
123
+ DEGREE: "bg-fuchsia-50 border-fuchsia-200 text-fuchsia-700",
124
+ CAUSE: "bg-orange-50 border-orange-200 text-orange-700",
125
+ RESULT: "bg-lime-50 border-lime-200 text-lime-700",
126
+ REL: "bg-emerald-50 border-emerald-200 text-emerald-700",
127
+ EXIST: "bg-cyan-50 border-cyan-200 text-cyan-700",
128
+ BA: "bg-yellow-50 border-yellow-200 text-yellow-700",
129
+ BEI: "bg-stone-50 border-stone-200 text-stone-700",
130
+ AGENT: "bg-stone-50 border-stone-200 text-stone-800",
131
+ OTHER: "bg-zinc-50 border-zinc-200 text-zinc-700",
132
+ };
133
+
134
+ // Prevent drag initiation from form controls inside draggable cards
135
+ const NO_DRAG = {
136
+ draggable: false,
137
+ onDragStart: (e) => e.preventDefault(),
138
+ onMouseDown: (e) => e.stopPropagation(),
139
+ };
140
+
141
+ function RolePill({ role }) {
142
+ const cls = ROLE_COLOUR[role] || ROLE_COLOUR.OTHER;
143
+ return <span className={`inline-flex items-center px-2 py-0.5 rounded-lg border text-[11px] ${cls}`}>{ROLE_LABEL[role] || role}</span>;
144
+ }
145
+
146
+ function RoleSelector({ value, onChange }) {
147
+ return (
148
+ <select {...NO_DRAG} className="rounded-lg border border-zinc-300 px-2 py-1 text-xs" value={value} onChange={(e) => onChange(e.target.value)}>
149
+ {ROLES.map((r) => (
150
+ <option key={r} value={r}>{ROLE_LABEL[r]}</option>
151
+ ))}
152
+ </select>
153
+ );
154
+ }
155
+
156
+ function SortableCard({ id, idx, src, lit, role, onEdit, onDragStart, onDragEnter, onDragOver, onDrop, onDragEnd, onKeyMove, isDragOver, highlightRoles }) {
157
+ const isHL = highlightRoles?.includes(role);
158
+ return (
159
+ <div
160
+ data-id={id}
161
+ draggable
162
+ onDragStart={(e) => onDragStart(e, id)
163
+ }
164
+ onDragEnter={(e) => onDragEnter(e, id)}
165
+ onDragOver={(e) => onDragOver(e, id)}
166
+ onDrop={(e) => onDrop(e, id)}
167
+ onDragEnd={onDragEnd}
168
+ 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" : ""}`}
169
+ tabIndex={0}
170
+ onKeyDown={(e) => {
171
+ if (e.key === "ArrowUp") { e.preventDefault(); onKeyMove(id, -1); }
172
+ if (e.key === "ArrowDown") { e.preventDefault(); onKeyMove(id, +1); }
173
+ }}
174
+ >
175
+ <div className="flex items-center justify-between mb-2">
176
+ <div className="flex items-center gap-2">
177
+ <div className="text-xs uppercase tracking-wider text-zinc-500">Chunk {idx + 1}</div>
178
+ <RolePill role={role} />
179
+ </div>
180
+ <div className="flex items-center gap-2">
181
+ <RoleSelector value={role} onChange={(r) => onEdit(id, { role: r })} />
182
+ <div className="flex gap-1">
183
+ <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>
184
+ <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>
185
+ </div>
186
+ </div>
187
+ </div>
188
+
189
+ {/* Grab bar / affordance */}
190
+ <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">
191
+ ⠿ Drag anywhere on the card background • or use ▲▼ keys
192
+ </div>
193
+
194
+ <div className="grid md:grid-cols-2 gap-3">
195
+ <label className="text-sm text-zinc-600">
196
+ <span className="block text-xs text-zinc-500 mb-1">Source phrase</span>
197
+ <input
198
+ {...NO_DRAG}
199
+ className="w-full rounded-xl border border-zinc-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
200
+ value={src}
201
+ onChange={(e) => onEdit(id, { src: e.target.value })}
202
+ />
203
+ </label>
204
+ <label className="text-sm text-zinc-600">
205
+ <span className="block text-xs text-zinc-500 mb-1">Literal translation</span>
206
+ <input
207
+ {...NO_DRAG}
208
+ className="w-full rounded-xl border border-zinc-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
209
+ value={lit}
210
+ onChange={(e) => onEdit(id, { lit: e.target.value })}
211
+ />
212
+ </label>
213
+ </div>
214
+ </div>
215
+ );
216
+ }
217
+
218
+ // ------------------------
219
+ // Self-tests (expanded)
220
+ // ------------------------
221
+ function runTokenizerTests() {
222
+ const cases = [
223
+ { name: "empty string", input: "", expect: [] },
224
+ { name: "spaces only", input: " \t\n ", expect: [] },
225
+ { name: "simple EN", input: "I like apples.", expect: ["I", "like", "apples", "."] },
226
+ { name: "simple ZH", input: "我喜欢中文。", expect: ["我", "喜欢", "中文", "。"] },
227
+ { name: "ZH+quotes", input: "“你好”,世界!", expect: ["“", "你", "好", "”", ",", "世", "界", "!"] },
228
+ { name: "EN hyphen+apostrophe", input: "state-of-the-art don't fail.", expect: ["state-of-the-art", "don't", "fail", "."] },
229
+ { name: "Emoji and symbols", input: "Hi🙂!", expect: ["Hi", "🙂", "!"] },
230
+ { name: "ZH with spaces", input: "我 爱 你。", expect: ["我", "爱", "你", "。"] },
231
+ ];
232
+ return cases.map((c) => {
233
+ const got = tokenize(c.input);
234
+ const pass = Array.isArray(got) && got.join("|") === c.expect.join("|");
235
+ return { name: c.name, pass, got, expect: c.expect };
236
+ });
237
+ }
238
+
239
+ function runBoundaryTests() {
240
+ const toks = tokenize("我 喜欢 中文 .");
241
+ const expectedCount = Math.max(0, toks.length - 1);
242
+ const b1 = boundariesFromPunctuation(toks);
243
+ const safeNewTrue = new Array(Math.max(0, toks.length - 1)).fill(true);
244
+ const safeNewFalse = new Array(Math.max(0, toks.length - 1)).fill(false);
245
+ return [
246
+ { name: "boundary count safe", pass: b1.length === expectedCount, got: b1.length, expect: expectedCount },
247
+ { name: "new true safe", pass: safeNewTrue.length === expectedCount },
248
+ { name: "new false safe", pass: safeNewFalse.length === expectedCount },
249
+ ];
250
+ }
251
+
252
+ function runLiteralSplitTests() {
253
+ const groups = ["It is", "difficult", "for me", "to learn Chinese"];
254
+ const t1 = splitLiteralChunks("很难 | 对我来说 | 学习中文", groups);
255
+ const t2 = splitLiteralChunks("很难\n对我来说\n学习中文", groups);
256
+ const t3 = splitLiteralChunks("很 难 对 我 来 说 学 习 中 文", groups);
257
+ return [
258
+ { name: "literal with pipes", pass: Array.isArray(t1) && t1.length === groups.length },
259
+ { name: "literal with newlines", pass: Array.isArray(t2) && t2.length === groups.length },
260
+ { name: "literal with words", pass: Array.isArray(t3) && t3.length === groups.length },
261
+ ];
262
+ }
263
+
264
+ function runCharExplodeTests() {
265
+ const base = ["我喜欢中文", "。"];
266
+ const exploded = explodeHanChars(base);
267
+ const pass = exploded.join("") === "我喜欢中文。" && exploded.length === 6; // 我 喜 欢 中 文 。
268
+ return [{ name: "explode Han into characters", pass, got: exploded }];
269
+ }
270
+
271
+ function runNormalizeBoundaryTests() {
272
+ const toks = ["我","喜","欢","中","文","。"];
273
+ const b = [true, false, true];
274
+ const n = Math.max(0, toks.length - 1);
275
+ const out = new Array(n).fill(false);
276
+ for (let i = 0; i < Math.min(n, b.length); i++) out[i] = !!b[i];
277
+ return [{ name: "normalize boundaries len", pass: out.length === toks.length - 1 }];
278
+ }
279
+
280
+ function SelfTests() {
281
+ const tokResults = runTokenizerTests();
282
+ const bResults = runBoundaryTests();
283
+ const lResults = runLiteralSplitTests();
284
+ const xResults = runCharExplodeTests();
285
+ const nbResults = runNormalizeBoundaryTests();
286
+ const all = [...tokResults, ...bResults, ...lResults, ...xResults, ...nbResults];
287
+ const passed = all.filter((r) => r.pass).length;
288
+ const total = all.length;
289
+ return (
290
+ <details className="mt-6">
291
+ <summary className="cursor-pointer text-sm text-zinc-600">Self-tests: {passed}/{total} passed (click to view)</summary>
292
+ <div className="mt-3 text-xs grid gap-2">
293
+ {all.map((r, i) => (
294
+ <div key={i} className={`p-2 rounded-lg border ${r.pass ? "border-emerald-300 bg-emerald-50" : "border-rose-300 bg-rose-50"}`}>
295
+ <div className="font-medium">{r.name} — {r.pass ? "PASS" : "FAIL"}</div>
296
+ {r.expect !== undefined && (
297
+ <div className="mt-1">
298
+ <div><span className="text-zinc-500">expect:</span> {JSON.stringify(r.expect)}</div>
299
+ <div><span className="text-zinc-500">got:</span> {JSON.stringify(r.got)}</div>
300
+ </div>
301
+ )}
302
+ </div>
303
+ ))}
304
+ </div>
305
+ </details>
306
+ );
307
+ }
308
+
309
+ // ---------- Pattern cards data with examples ----------
310
+ const PATTERNS = [
311
+ {
312
+ id: "it_adj",
313
+ title: "It is ADJ for SB to VP → 对SB来说 + VP + 很ADJ",
314
+ roles: ["TOPIC", "SUBJECT", "VERB", "OBJECT", "DEGREE"],
315
+ hint: "Front stance/topic, then VP, then degree+adj (simplified as DEGREE).",
316
+ exEn: "It is difficult for me to learn Chinese.",
317
+ exZh: "对我来说,学中文很难。",
318
+ },
319
+ {
320
+ id: "rel_clause",
321
+ title: "Relative clause → [REL] 的 + N / EN: N + (that/which …)",
322
+ roles: ["REL", "OBJECT"],
323
+ hint: "ZH: 把后置从句前置为 的‑短语;EN: RC follows the noun.",
324
+ exEn: "the book that I bought yesterday is expensive",
325
+ exZh: "我昨天买的书很贵。",
326
+ },
327
+ {
328
+ id: "existential",
329
+ title: "Existential/there is → (PLACE) + 有 + NP / EN: there is/are + NP (PP)",
330
+ roles: ["PLACE", "EXIST", "OBJECT"],
331
+ hint: "ZH: 地点可前置,EXIST 代表 ‘有’;EN: ‘there is/are’ + NP (+ place/time).",
332
+ exEn: "There is a cat in the room.",
333
+ exZh: "房间里有一只猫。",
334
+ },
335
+ {
336
+ id: "ba",
337
+ title: "Disposal 把 → 把 + OBJ + V + (RESULT)",
338
+ roles: ["BA", "OBJECT", "VERB", "RESULT"],
339
+ hint: "突出对宾语的处置;结果补语可选。",
340
+ exEn: "I put the keys on the table.",
341
+ exZh: "我把钥匙放在桌子上。",
342
+ },
343
+ {
344
+ id: "bei",
345
+ title: "Passive 被 → OBJ + 被 + AGENT + V (+RESULT) / EN: AGENT + V + OBJ (or passive)",
346
+ roles: ["OBJECT", "BEI", "AGENT", "VERB", "RESULT"],
347
+ hint: "ZH: AGENT 明示时放在 被 之后;EN: passive optional; active often preferred.",
348
+ exEn: "The door was opened by the wind.",
349
+ exZh: "门被风吹开了。",
350
+ },
351
+ ];
352
+
353
+ function ReasoningHints({ direction, cards }) {
354
+ if (!cards || cards.length === 0) return null;
355
+ const roles = cards.map((c) => c.role);
356
+ const hints = [];
357
+ const has = (r) => roles.includes(r);
358
+ if (direction.startsWith("EN")) {
359
+ if (has("TIME") || has("PLACE") || has("TOPIC")) hints.push("Fronted TIME/PLACE/TOPIC reflects Chinese topic/comment tendency.");
360
+ if (has("REL") && has("OBJECT")) hints.push("Prenominal 的‑relative clause before the noun (ZH).");
361
+ if (has("BA")) hints.push("把‑construction focuses disposal/result on the object.");
362
+ if (has("BEI")) hints.push("被‑passive highlights the patient/topic; agent optional.");
363
+ if (has("EXIST")) hints.push("Existential 有 introduces new entities: (Place)+有+NP.");
364
+ } else {
365
+ if (has("REL") && has("OBJECT")) hints.push("English relative clauses follow the noun: N + (that/which …).");
366
+ if (has("EXIST")) hints.push("Translate 存现句 with 'there is/are' when introducing new entities.");
367
+ if (has("TIME") || has("PLACE")) hints.push("English often places time/place adverbials after the verb phrase or at sentence end.");
368
+ }
369
+ if (hints.length === 0) return null;
370
+ return (
371
+ <ul className="list-disc ml-5">{hints.map((h, i) => <li key={i}>{h}</li>)}</ul>
372
+ );
373
+ }
374
+
375
+ export default function SyntaxReorderer() {
376
+ const [direction, setDirection] = useState("EN → ZH");
377
+ const [sourceText, setSourceText] = useState("");
378
+ const [literalText, setLiteralText] = useState("");
379
+
380
+ const prevLiteralRef = useRef("");
381
+ const fileRef = useRef(null);
382
+
383
+ // Character-separator mode (ZH)
384
+ const [zhCharMode, setZhCharMode] = useState(false);
385
+
386
+ const baseTokens = useMemo(() => tokenize(sourceText), [sourceText]);
387
+ const hasHanInSource = useMemo(() => /\p{Script=Han}/u.test(sourceText), [sourceText]);
388
+ const srcTokens = useMemo(() => (zhCharMode ? explodeHanChars(baseTokens) : baseTokens), [baseTokens, zhCharMode]);
389
+
390
+ const [boundaries, setBoundaries] = useState([]);
391
+
392
+ React.useEffect(() => {
393
+ setBoundaries((prev) => {
394
+ const count = boundaryCount(srcTokens);
395
+ const next = new Array(count).fill(false);
396
+ for (let i = 0; i < Math.min(prev.length, next.length); i++) next[i] = prev[i];
397
+ return next;
398
+ });
399
+ }, [srcTokens.length]);
400
+
401
+ const groups = useMemo(() => groupsFrom(srcTokens, boundaries), [srcTokens, boundaries]);
402
+ const groupsKey = useMemo(() => groups.join("|"), [groups]);
403
+
404
+ const [cards, setCards] = useState([]); // {id, src, lit, role}
405
+ const [order, setOrder] = useState([]);
406
+ const [joiner, setJoiner] = useState("auto");
407
+
408
+ const [autoBuild, setAutoBuild] = useState(true);
409
+ const [hasDragged, setHasDragged] = useState(false);
410
+
411
+ const [dragId, setDragId] = useState(null);
412
+ const [dragOverId, setDragOverId] = useState(null);
413
+ const [isDragging, setIsDragging] = useState(false);
414
+
415
+ // Pattern highlight state
416
+ const [highlightRoles, setHighlightRoles] = useState([]);
417
+
418
+ function rebuild({ preserveByIndex }) {
419
+ const litChunks = splitLiteralChunks(literalText, groups);
420
+ const built = groups.map((g, i) => ({ id: `${i}-${hash6(g)}`, src: g, lit: litChunks[i] ?? "", role: cards[i]?.role || "OTHER" }));
421
+
422
+ if (preserveByIndex && cards.length === built.length) {
423
+ for (let i = 0; i < built.length; i++) {
424
+ if (cards[i]) {
425
+ if (typeof cards[i].lit === "string" && cards[i].lit.trim() !== "") built[i].lit = cards[i].lit;
426
+ if (cards[i].role) built[i].role = cards[i].role;
427
+ }
428
+ }
429
+ }
430
+
431
+ setCards(built);
432
+ setOrder((prev) => {
433
+ if (prev.length === built.length) {
434
+ const setIds = new Set(built.map((b) => b.id));
435
+ const prevFiltered = prev.filter((id) => setIds.has(id));
436
+ if (prevFiltered.length === built.length) return prevFiltered;
437
+ }
438
+ return built.map((c) => c.id);
439
+ });
440
+
441
+ if (joiner === "auto") {
442
+ const anyHan = built.some((c) => containsHan(c.lit));
443
+ setJoiner(anyHan ? "none" : "space");
444
+ }
445
+ }
446
+
447
+ React.useEffect(() => {
448
+ if (!autoBuild || hasDragged) return;
449
+ const prevLit = prevLiteralRef.current;
450
+ const litChanged = prevLit !== literalText;
451
+ rebuild({ preserveByIndex: !litChanged });
452
+ prevLiteralRef.current = literalText;
453
+ // eslint-disable-next-line react-hooks/exhaustive-deps
454
+ }, [groupsKey, literalText, autoBuild, hasDragged]);
455
+
456
+ // Helpers for drag guard
457
+ function startedOnInteractiveTarget(e) {
458
+ const path = (e.nativeEvent && typeof e.nativeEvent.composedPath === 'function') ? e.nativeEvent.composedPath() : [];
459
+ for (const el of path) {
460
+ const tag = el && el.tagName;
461
+ if (!tag) continue;
462
+ if (["INPUT", "TEXTAREA", "SELECT", "BUTTON"].includes(tag)) return true;
463
+ }
464
+ return false;
465
+ }
466
+
467
+ // HTML5 DnD handlers
468
+ function handleCardDragStart(e, id) {
469
+ if (startedOnInteractiveTarget(e)) { e.preventDefault(); return; }
470
+ setDragId(id);
471
+ setIsDragging(true);
472
+ e.dataTransfer.effectAllowed = "move";
473
+ try { e.dataTransfer.setData("text/plain", String(id)); } catch {}
474
+ }
475
+ function handleCardDragEnter(e, overId) { if (!isDragging) return; e.preventDefault(); setDragOverId(overId); }
476
+ function handleCardDragOver(e, overId) { if (!isDragging) return; e.preventDefault(); setDragOverId(overId); }
477
+ function handleCardDrop(e, overId) {
478
+ if (!isDragging) return;
479
+ e.preventDefault();
480
+ const fromId = dragId || e.dataTransfer.getData("text/plain");
481
+ if (!fromId || !overId || fromId === overId) { setDragId(null); setDragOverId(null); setIsDragging(false); return; }
482
+ setOrder((items) => {
483
+ const from = items.indexOf(fromId);
484
+ const to = items.indexOf(overId);
485
+ if (from === -1 || to === -1) return items;
486
+ return moveItem(items, from, to);
487
+ });
488
+ setHasDragged(true); setDragId(null); setDragOverId(null); setIsDragging(false);
489
+ }
490
+ function handleCardDragEnd() {
491
+ setIsDragging(false);
492
+ setDragId(null);
493
+ setDragOverId(null);
494
+ }
495
+
496
+ function handleKeyMove(id, delta) {
497
+ setOrder((items) => {
498
+ const i = items.indexOf(id);
499
+ if (i === -1) return items;
500
+ let j = i + delta; if (j < 0) j = 0; if (j >= items.length) j = items.length - 1; if (i === j) return items;
501
+ return moveItem(items, i, j);
502
+ });
503
+ setHasDragged(true);
504
+ }
505
+
506
+ function onEditCard(id, patch) { setCards((prev) => prev.map((c) => (c.id === id ? { ...c, ...patch } : c))); }
507
+
508
+ // Compose outputs
509
+ const orderedCards = useMemo(() => order.map((id) => cards.find((c) => c.id === id)).filter(Boolean), [order, cards]);
510
+ const composedTarget = useMemo(() => {
511
+ const raw = orderedCards.map((c) => c.lit).filter((s) => s != null);
512
+ if ((joiner === "none") || (joiner === "auto" && raw.some(containsHan))) return raw.join("");
513
+ return raw.join(" ");
514
+ }, [orderedCards, joiner]);
515
+
516
+ // Clear helpers
517
+ const clearSource = () => { setSourceText(""); setBoundaries([]); setHasDragged(false); };
518
+ const clearLiteral = () => { setLiteralText(""); setHasDragged(false); };
519
+ const clearCards = () => { setCards([]); setOrder([]); setHasDragged(false); };
520
+
521
+ function exportJSON() {
522
+ const payload = { direction, sourceText, literalText, tokens: srcTokens, boundaries, groups, cards, order, joiner, composedTarget, timestamp: new Date().toISOString() };
523
+ const json = JSON.stringify(payload, null, 2);
524
+ try {
525
+ const blob = new Blob([json], { type: "application/json;charset=utf-8" });
526
+ const url = URL.createObjectURL(blob);
527
+ const a = document.createElement("a");
528
+ a.href = url;
529
+ a.download = "syntax-reorderer-activity.json";
530
+ a.rel = "noopener";
531
+ document.body.appendChild(a);
532
+ a.click();
533
+ setTimeout(() => { try { document.body.removeChild(a); } catch {} URL.revokeObjectURL(url); }, 0);
534
+ } catch (err) {
535
+ // Fallback: copy to clipboard
536
+ try {
537
+ if (navigator.clipboard && navigator.clipboard.writeText) {
538
+ navigator.clipboard.writeText(json);
539
+ alert("Exported by copying JSON to clipboard.");
540
+ } else {
541
+ alert("Export failed: " + (err?.message || String(err)));
542
+ }
543
+ } catch (e2) {
544
+ alert("Export failed: " + (err?.message || String(err)));
545
+ }
546
+ }
547
+ }
548
+
549
+ function normalizeBoundaries(b, tokens) {
550
+ const n = Math.max(0, (tokens?.length || 0) - 1);
551
+ const out = new Array(n).fill(false);
552
+ if (Array.isArray(b)) {
553
+ for (let i = 0; i < Math.min(n, b.length); i++) out[i] = !!b[i];
554
+ }
555
+ return out;
556
+ }
557
+
558
+ function importJSON(file) {
559
+ const reader = new FileReader();
560
+ reader.onload = (e) => {
561
+ try {
562
+ const text = String(e.target?.result || "");
563
+ const data = JSON.parse(text);
564
+ const src = typeof data.sourceText === "string" ? data.sourceText : "";
565
+ const base = tokenize(src);
566
+ const chars = explodeHanChars(base);
567
+ const wantCharMode = (/\p{Script=Han}/u.test(src)) && Array.isArray(data.tokens) && data.tokens.length === chars.length;
568
+
569
+ setDirection(typeof data.direction === "string" ? data.direction : direction);
570
+ setZhCharMode(wantCharMode);
571
+ setSourceText(src);
572
+ const toks = wantCharMode ? chars : base;
573
+ setBoundaries(normalizeBoundaries(data.boundaries, toks));
574
+ setLiteralText(typeof data.literalText === "string" ? data.literalText : "");
575
+ setCards(Array.isArray(data.cards) ? data.cards : []);
576
+ setOrder(Array.isArray(data.order) ? data.order : []);
577
+ setJoiner(data.joiner === "space" || data.joiner === "none" ? data.joiner : "auto");
578
+ prevLiteralRef.current = typeof data.literalText === "string" ? data.literalText : "";
579
+ setHasDragged(false);
580
+ } catch (err) {
581
+ alert("Invalid JSON");
582
+ }
583
+ };
584
+ reader.readAsText(file);
585
+ }
586
+
587
+ function loadDemo() {
588
+ if (direction.startsWith("EN")) {
589
+ // EN → ZH demo
590
+ const demoSrc = "It is difficult for me to learn Chinese.";
591
+ const demoLit = "很难 | 对我来说 | 学习中文";
592
+ setDirection("EN → ZH");
593
+ setSourceText(demoSrc);
594
+ setBoundaries(boundariesFromPunctuation(tokenize(demoSrc)));
595
+ setLiteralText(demoLit);
596
+ setHasDragged(false);
597
+ prevLiteralRef.current = demoLit;
598
+ setZhCharMode(false);
599
+ } else {
600
+ // ZH → EN demo
601
+ const demoSrc = "对我来说,学中文很难。";
602
+ const demoLit = "for me | learn Chinese | very difficult";
603
+ setDirection("ZH → EN");
604
+ setSourceText(demoSrc);
605
+ // Default to char separators for Chinese demo
606
+ const base = tokenize(demoSrc);
607
+ const chars = explodeHanChars(base);
608
+ setBoundaries(boundariesFromPunctuation(chars));
609
+ setZhCharMode(true);
610
+ setLiteralText(demoLit);
611
+ setHasDragged(false);
612
+ prevLiteralRef.current = demoLit;
613
+ }
614
+ }
615
+
616
+ const literalChunks = useMemo(() => splitLiteralChunks(literalText, groups), [literalText, groups]);
617
+
618
+ // Pattern helpers
619
+ function applyPatternOrder(roleSequence) {
620
+ setOrder((prev) => {
621
+ const seqIndex = new Map(roleSequence.map((r, i) => [r, i]));
622
+ const withIdx = prev.map((id, i) => {
623
+ const card = cards.find((c) => c.id === id);
624
+ const rank = seqIndex.has(card?.role) ? seqIndex.get(card.role) : 999;
625
+ return { id, i, rank };
626
+ });
627
+ withIdx.sort((a, b) => (a.rank - b.rank) || (a.i - b.i));
628
+ return withIdx.map((x) => x.id);
629
+ });
630
+ }
631
+
632
+ function toggleHighlightRoles(roleSequence) {
633
+ setHighlightRoles((prev) => {
634
+ const same = prev.length === roleSequence.length && prev.every((r, i) => r === roleSequence[i]);
635
+ return same ? [] : roleSequence.slice();
636
+ });
637
+ }
638
+
639
+ // ------- Render -------
640
+ return (
641
+ <div className="min-h-screen bg-zinc-50 text-zinc-900 p-6 md:p-10">
642
+ <div className="max-w-6xl mx-auto">
643
+ <header className="mb-6">
644
+ <h1 className="text-2xl md:text-3xl font-semibold tracking-tight">EN↔ZH Syntax Reorderer</h1>
645
+ <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>
646
+ </header>
647
+
648
+ {/* Controls */}
649
+ <div className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm mb-6">
650
+ <div className="flex flex-wrap gap-3 items-center">
651
+ <label className="text-sm" title="Target language direction; affects the recipe strip only.">Direction
652
+ <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)}>
653
+ <option>EN → ZH</option>
654
+ <option>ZH → EN</option>
655
+ </select>
656
+ </label>
657
+
658
+ <label className="text-sm" title="How to join the final literal chunks.">Joiner
659
+ <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)}>
660
+ <option value="auto">auto</option>
661
+ <option value="space">space</option>
662
+ <option value="none">none</option>
663
+ </select>
664
+ </label>
665
+
666
+ <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.">
667
+ <label className="text-sm flex items-center gap-2">
668
+ <input type="checkbox" className="scale-110" checked={autoBuild} onChange={(e) => { setAutoBuild(e.target.checked); if (e.target.checked) setHasDragged(false); }} />
669
+ Auto‑build
670
+ </label>
671
+ {hasDragged && autoBuild && (
672
+ <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>
673
+ )}
674
+ </div>
675
+
676
+ <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>
677
+ <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>
678
+ <button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" onClick={loadDemo}>Load demo</button>
679
+ <button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" onClick={exportJSON}>Export JSON</button>
680
+ <button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" onClick={() => fileRef.current?.click()}>Import JSON</button>
681
+ <input ref={fileRef} type="file" accept="application/json" className="hidden" onChange={(e) => e.target.files?.[0] && importJSON(e.target.files[0])} />
682
+ </div>
683
+
684
+ {/* Recipe strip — switches with direction */}
685
+ {direction.startsWith("EN") ? (
686
+ <div className="mt-4">
687
+ <div className="text-xs text-zinc-500 mb-1">Chinese “light recipe” (typical order):</div>
688
+ <div className="flex flex-wrap gap-2">
689
+ {["TIME","PLACE","TOPIC","SUBJECT","MANNER","DEGREE","VERB","OBJECT","RESULT"].map((r) => (
690
+ <span key={r} className={`px-2 py-1 rounded-lg border text-xs ${ROLE_COLOUR[r]}`}>{ROLE_LABEL[r]}</span>
691
+ ))}
692
+ </div>
693
+ </div>
694
+ ) : (
695
+ <div className="mt-4">
696
+ <div className="text-xs text-zinc-500 mb-1">English “light recipe” (typical clause order):</div>
697
+ <div className="flex flex-wrap gap-2">
698
+ {["TIME","SUBJECT","AUX","DEGREE","MANNER","VERB","OBJECT","PLACE","RESULT"].map((r) => (
699
+ <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>
700
+ ))}
701
+ </div>
702
+ <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>
703
+ </div>
704
+ )}
705
+ </div>
706
+
707
+ {/* What are roles? */}
708
+ <details className="mb-6 bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm">
709
+ <summary className="cursor-pointer font-medium">What are “roles” and what do these buttons mean?</summary>
710
+ <div className="mt-3 text-sm text-zinc-700 grid gap-2">
711
+ <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>
712
+ <ul className="list-disc ml-5">
713
+ <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>
714
+ <li><strong>Rebuild now</strong>: forces an immediate rebuild (useful if Auto‑build is off or paused).</li>
715
+ <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>
716
+ </ul>
717
+ </div>
718
+ </details>
719
+
720
+ {/* Pattern cards (collapsed by default) */}
721
+ <details className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm mb-6">
722
+ <summary className="cursor-pointer font-medium">Pattern cards</summary>
723
+ <div className="mt-3 grid md:grid-cols-2 gap-3">
724
+ {PATTERNS.map((p) => {
725
+ const matchCount = cards.filter((c) => p.roles.includes(c.role)).length;
726
+ return (
727
+ <div key={p.id} className="p-3 rounded-xl border border-zinc-200 bg-zinc-50">
728
+ <div className="text-sm font-medium flex items-center justify-between">
729
+ <span>{p.title}</span>
730
+ <span className="text-[11px] px-2 py-0.5 rounded-full bg-white border border-zinc-200">matches: {matchCount}</span>
731
+ </div>
732
+ <div className="text-xs text-zinc-600 mt-1">{p.hint}</div>
733
+ <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>
734
+ <div className="flex flex-wrap gap-2 mt-2">
735
+ {p.roles.map((r) => (
736
+ <span key={r} className={`px-2 py-0.5 rounded-lg border text-[11px] ${ROLE_COLOUR[r]}`}>{ROLE_LABEL[r]}</span>
737
+ ))}
738
+ </div>
739
+ <div className="mt-2 flex gap-2">
740
+ <button className="text-xs px-2 py-1 rounded-md bg-zinc-100 hover:bg-zinc-200" onClick={() => toggleHighlightRoles(p.roles)}>Highlight roles</button>
741
+ <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>
742
+ </div>
743
+ </div>
744
+ );
745
+ })}
746
+ </div>
747
+ {highlightRoles.length > 0 && (
748
+ <div className="text-xs text-zinc-500 mt-2">Highlighting: {highlightRoles.map((r) => ROLE_LABEL[r]).join(" → ")}</div>
749
+ )}
750
+ </details>
751
+
752
+ {/* Sentence + literal inputs */}
753
+ <div className="grid md:grid-cols-2 gap-6 mb-6">
754
+ <div className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm">
755
+ <div className="flex items-center justify-between mb-2">
756
+ <h2 className="font-medium">1) Source sentence</h2>
757
+ <button className="text-xs px-2 py-1 rounded-md bg-rose-50 hover:bg-rose-100" onClick={clearSource}>Clear source</button>
758
+ </div>
759
+ <textarea
760
+ {...NO_DRAG}
761
+ 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"
762
+ placeholder={direction.startsWith("EN") ? "Paste English here…" : "粘贴中文句子…"}
763
+ value={sourceText}
764
+ onChange={(e) => setSourceText(e.target.value)}
765
+ />
766
+
767
+ <div className="mt-2 flex items-center gap-3">
768
+ {hasHanInSource && (
769
+ <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.">
770
+ <input type="checkbox" className="scale-110" checked={zhCharMode} onChange={(e) => {
771
+ const checked = e.target.checked; setZhCharMode(checked);
772
+ const base = tokenize(sourceText);
773
+ const toks = checked ? explodeHanChars(base) : base;
774
+ setBoundaries(new Array(Math.max(0, toks.length - 1)).fill(false));
775
+ setHasDragged(false);
776
+ }} />
777
+ Character separators (ZH)
778
+ </label>
779
+ )}
780
+ </div>
781
+
782
+ <div className="mt-3">
783
+ <div className="text-xs text-zinc-500 mb-2">Click separators to toggle phrase boundaries. Cards update below.</div>
784
+ <div className="bg-zinc-50 border border-zinc-200 rounded-xl p-3 overflow-x-auto">
785
+ {srcTokens.length === 0 ? (
786
+ <div className="text-zinc-400 text-sm">Tokens will appear here…</div>
787
+ ) : (
788
+ <div className="flex flex-wrap items-center gap-1">
789
+ {srcTokens.map((t, i) => (
790
+ <React.Fragment key={i}>
791
+ <span className={`px-2 py-1 rounded-lg border ${isPunc(t) ? "border-amber-300 bg-amber-50" : "border-zinc-300 bg-white"}`}>{t}</span>
792
+ {i < srcTokens.length - 1 && (
793
+ <button
794
+ 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"}`}
795
+ onClick={() => { setBoundaries((prev) => prev.map((b, j) => (j === i ? !b : b))); setHasDragged(false); }}
796
+ title="Toggle boundary"
797
+ >
798
+ |
799
+ </button>
800
+ )}
801
+ </React.Fragment>
802
+ ))}
803
+ </div>
804
+ )}
805
+ </div>
806
+
807
+ <div className="mt-3">
808
+ <div className="text-xs text-zinc-500 mb-1">Chunk preview ({groups.length}):</div>
809
+ <div className="flex flex-wrap gap-2">
810
+ {groups.length === 0 ? (
811
+ <span className="text-zinc-400 text-sm">(Add boundaries to see chunks)</span>
812
+ ) : (
813
+ groups.map((g, idx) => (
814
+ <span key={idx} className="px-2 py-1 rounded-lg border border-zinc-300 bg-white text-sm">{g}</span>
815
+ ))
816
+ )}
817
+ </div>
818
+ </div>
819
+
820
+ <div className="flex flex-wrap gap-2 mt-3">
821
+ <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>
822
+ <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>
823
+ <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>
824
+ </div>
825
+ </div>
826
+ </div>
827
+
828
+ <div className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm">
829
+ <div className="flex items-center justify-between mb-2">
830
+ <h2 className="font-medium">2) Literal translation (phrase list)</h2>
831
+ <button className="text-xs px-2 py-1 rounded-md bg-rose-50 hover:bg-rose-100" onClick={clearLiteral}>Clear literal</button>
832
+ </div>
833
+ <textarea
834
+ {...NO_DRAG}
835
+ 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"
836
+ 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)"}
837
+ value={literalText}
838
+ onChange={(e) => { setLiteralText(e.target.value); setHasDragged(false); }}
839
+ />
840
+ <div className="text-xs text-zinc-500 mt-2">Literal chunks detected: <strong>{literalChunks.length}</strong>. Source chunks: <strong>{groups.length}</strong>.
841
+ {literalChunks.length !== groups.length && <span className="text-rose-600"> — counts don’t match; extra/short chunks will be ignored/padded.</span>}
842
+ </div>
843
+ </div>
844
+ </div>
845
+
846
+ {/* Drag board */}
847
+ <div className="grid md:grid-cols-2 gap-6">
848
+ <div className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm">
849
+ <div className="flex items-center justify-between mb-2">
850
+ <h2 className="font-medium">Phrase cards</h2>
851
+ <button className="text-xs px-2 py-1 rounded-md bg-rose-50 hover:bg-rose-100" onClick={clearCards}>Clear cards</button>
852
+ </div>
853
+ {cards.length === 0 ? (
854
+ <div className="text-sm text-zinc-500">No cards yet. Add text above.</div>
855
+ ) : (
856
+ order.map((id, idx) => {
857
+ const c = cards.find((x) => x.id === id);
858
+ if (!c) return null;
859
+ return (
860
+ <SortableCard
861
+ key={id}
862
+ id={id}
863
+ idx={idx}
864
+ src={c.src}
865
+ lit={c.lit}
866
+ role={c.role || "OTHER"}
867
+ onEdit={onEditCard}
868
+ onDragStart={handleCardDragStart}
869
+ onDragEnter={handleCardDragEnter}
870
+ onDragOver={handleCardDragOver}
871
+ onDrop={handleCardDrop}
872
+ onKeyMove={handleKeyMove}
873
+ onDragEnd={handleCardDragEnd}
874
+ isDragOver={dragOverId === id}
875
+ highlightRoles={highlightRoles}
876
+ />
877
+ );
878
+ })
879
+ )}
880
+ </div>
881
+
882
+ <div className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm">
883
+ <h2 className="font-medium mb-2">Result</h2>
884
+ <div className="text-xs text-zinc-500 mb-2">Joined literal translation in the new order:</div>
885
+ <div className="min-h-[64px] border border-zinc-200 rounded-xl p-3 bg-zinc-50 text-lg leading-8">
886
+ {composedTarget || <span className="text-zinc-400">(Will appear here once you reorder)</span>}
887
+ </div>
888
+
889
+ {/* Reasoning hints */}
890
+ <div className="mt-3 text-xs text-zinc-600">
891
+ <ReasoningHints direction={direction} cards={orderedCards} />
892
+ </div>
893
+
894
+ <div className="mt-4 grid gap-2">
895
+ <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>
896
+ <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>
897
+ </div>
898
+
899
+ {/* Self-tests intentionally not mounted */}
900
+ {/* <SelfTests /> */}
901
+ </div>
902
+ </div>
903
+ </div>
904
+ </div>
905
+ );
906
+ }
907
+
908
+