Spaces:
Sleeping
Sleeping
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 |
+
|