scripttrace 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,971 @@
1
+ const $ = (id) => document.getElementById(id);
2
+
3
+ const appEl = $("app");
4
+ const projectList = $("projectList");
5
+ const uploadHint = $("uploadHint");
6
+ const dropLabel = $("dropLabel");
7
+ const paneBody = $("paneBody");
8
+ const titleInput = $("titleInput");
9
+ const fileMeta = $("fileMeta");
10
+ const saveState = $("saveState");
11
+ const panes = $("panes");
12
+ const emptyState = $("emptyState");
13
+ const btnSidebar = $("btnSidebar");
14
+
15
+ const SIDEBAR_KEY = "scripttrace.sidebarCollapsed";
16
+ const CHUNK = 48;
17
+ const KIND_LABEL = {
18
+ dialogue: "对白",
19
+ action: "动作",
20
+ scene: "场次",
21
+ episode: "集数",
22
+ heading: "标题",
23
+ other: "其他",
24
+ };
25
+
26
+ let project = null;
27
+ let saveTimer = null;
28
+ let activeBlockId = null;
29
+ let renamingId = null;
30
+ let renderGen = 0;
31
+ let editingEl = null;
32
+
33
+ function setSaveState(text) {
34
+ saveState.textContent = text;
35
+ saveState.className = "status";
36
+ if (text === "未保存") saveState.classList.add("dirty");
37
+ else if (text.startsWith("保存失败")) saveState.classList.add("err");
38
+ else if (text.startsWith("加载") || text === "保存中…" || text === "正在导入…") saveState.classList.add("busy");
39
+ else saveState.classList.add("ok");
40
+ }
41
+
42
+ function isDirty() {
43
+ return saveState.textContent === "未保存";
44
+ }
45
+
46
+ function showWorkspace(on) {
47
+ panes.hidden = !on;
48
+ emptyState.hidden = on;
49
+ titleInput.disabled = !on;
50
+ $("editorActions").hidden = !on;
51
+ if (!on) {
52
+ titleInput.value = "";
53
+ fileMeta.textContent = "";
54
+ titleInput.placeholder = "从左侧导入或选择一个剧本";
55
+ }
56
+ }
57
+
58
+ function isSidebarCollapsed() {
59
+ return appEl.classList.contains("sidebar-collapsed");
60
+ }
61
+
62
+ function applySidebar(collapsed) {
63
+ appEl.classList.toggle("sidebar-collapsed", collapsed);
64
+ btnSidebar.textContent = collapsed ? "›" : "‹";
65
+ btnSidebar.title = collapsed ? "展开项目栏" : "折叠项目栏";
66
+ btnSidebar.setAttribute("aria-expanded", String(!collapsed));
67
+ const emptyBtn = $("btnEmptySidebar");
68
+ if (emptyBtn) emptyBtn.hidden = !collapsed;
69
+ try {
70
+ localStorage.setItem(SIDEBAR_KEY, collapsed ? "1" : "0");
71
+ } catch {
72
+ /* ignore */
73
+ }
74
+ }
75
+
76
+ function toggleSidebar() {
77
+ applySidebar(!isSidebarCollapsed());
78
+ }
79
+
80
+ function initSidebar() {
81
+ let collapsed = false;
82
+ try {
83
+ collapsed = localStorage.getItem(SIDEBAR_KEY) === "1";
84
+ } catch {
85
+ collapsed = false;
86
+ }
87
+ applySidebar(collapsed);
88
+ }
89
+
90
+ async function showEditor(data) {
91
+ project = data;
92
+ renamingId = null;
93
+ activeBlockId = null;
94
+ editingEl = null;
95
+ showWorkspace(true);
96
+ titleInput.value = data.title || "";
97
+ const src = data.source || {};
98
+ fileMeta.textContent = src.filename
99
+ ? `来源 ${src.filename} · ${data.blocks.length} 段 · 原文已锁定`
100
+ : "原文已锁定";
101
+ setSaveState("加载中…");
102
+ await renderPanes();
103
+ applyPairFilter();
104
+ setSaveState("已保存");
105
+ loadProjects();
106
+ }
107
+
108
+ async function errorText(res) {
109
+ const t = await res.text();
110
+ try {
111
+ const j = JSON.parse(t);
112
+ return j.detail || t;
113
+ } catch {
114
+ return t;
115
+ }
116
+ }
117
+
118
+ function defaultTitleFromFilename(filename) {
119
+ if (!filename) return "未命名";
120
+ const base = filename.replace(/^.*[\\/]/, "");
121
+ return base.replace(/\.[^.]+$/, "") || base;
122
+ }
123
+
124
+ async function loadProjects() {
125
+ const items = await (await fetch("/api/projects")).json();
126
+ projectList.innerHTML = "";
127
+ if (!items.length) {
128
+ const li = document.createElement("li");
129
+ li.className = "empty";
130
+ li.textContent = "还没有项目";
131
+ projectList.append(li);
132
+ return;
133
+ }
134
+ for (const item of items) {
135
+ const li = document.createElement("li");
136
+ li.className = "project-item";
137
+ if (project && project.id === item.id) li.classList.add("active");
138
+ li.dataset.id = item.id;
139
+
140
+ if (renamingId === item.id) {
141
+ const input = document.createElement("input");
142
+ input.className = "name-input";
143
+ input.value = item.title || "";
144
+ input.addEventListener("keydown", (e) => {
145
+ if (e.key === "Enter") {
146
+ e.preventDefault();
147
+ commitRename(item.id, input.value);
148
+ }
149
+ if (e.key === "Escape") {
150
+ renamingId = null;
151
+ loadProjects();
152
+ }
153
+ });
154
+ const actions = document.createElement("div");
155
+ actions.className = "rename-actions";
156
+ const save = document.createElement("button");
157
+ save.type = "button";
158
+ save.className = "save-name";
159
+ save.textContent = "确定";
160
+ save.addEventListener("click", () => commitRename(item.id, input.value));
161
+ const cancel = document.createElement("button");
162
+ cancel.type = "button";
163
+ cancel.className = "cancel-name";
164
+ cancel.textContent = "取消";
165
+ cancel.addEventListener("click", () => {
166
+ renamingId = null;
167
+ loadProjects();
168
+ });
169
+ actions.append(save, cancel);
170
+ const meta = document.createElement("span");
171
+ meta.className = "meta";
172
+ meta.textContent = `${item.block_count} 段`;
173
+ li.append(input, actions, meta);
174
+ projectList.append(li);
175
+ input.focus();
176
+ input.select();
177
+ continue;
178
+ }
179
+
180
+ const name = document.createElement("button");
181
+ name.type = "button";
182
+ name.className = "name";
183
+ name.textContent = item.title || defaultTitleFromFilename(item.source_filename);
184
+ name.title = "打开";
185
+ name.addEventListener("click", (e) => {
186
+ e.stopPropagation();
187
+ openProject(item.id);
188
+ });
189
+ name.addEventListener("dblclick", (e) => {
190
+ e.preventDefault();
191
+ e.stopPropagation();
192
+ startRename(item.id);
193
+ });
194
+
195
+ const ops = document.createElement("div");
196
+ ops.className = "item-ops";
197
+ const rename = document.createElement("button");
198
+ rename.type = "button";
199
+ rename.className = "rename";
200
+ rename.textContent = "改名";
201
+ rename.addEventListener("click", (e) => {
202
+ e.stopPropagation();
203
+ startRename(item.id);
204
+ });
205
+ const del = document.createElement("button");
206
+ del.type = "button";
207
+ del.className = "delete";
208
+ del.textContent = "删除";
209
+ del.addEventListener("click", (e) => {
210
+ e.stopPropagation();
211
+ deleteProject(item.id, item.title || defaultTitleFromFilename(item.source_filename));
212
+ });
213
+ ops.append(rename, del);
214
+
215
+ const meta = document.createElement("span");
216
+ meta.className = "meta";
217
+ const src = item.source_filename ? ` · ${item.source_filename}` : "";
218
+ meta.textContent = `${item.block_count} 段${src}`;
219
+
220
+ li.addEventListener("click", () => openProject(item.id));
221
+ li.append(name, ops, meta);
222
+ projectList.append(li);
223
+ }
224
+ }
225
+
226
+ function startRename(id) {
227
+ renamingId = id;
228
+ loadProjects();
229
+ }
230
+
231
+ async function deleteProject(id, title) {
232
+ const ok = window.confirm(`确定删除「${title}」?\n原文和改稿都会从本机删掉,不可恢复。`);
233
+ if (!ok) return;
234
+ const res = await fetch(`/api/projects/${id}`, { method: "DELETE" });
235
+ if (!res.ok) {
236
+ uploadHint.textContent = await errorText(res);
237
+ return;
238
+ }
239
+ if (project && project.id === id) {
240
+ project = null;
241
+ renderGen += 1;
242
+ paneBody.innerHTML = "";
243
+ showWorkspace(false);
244
+ }
245
+ loadProjects();
246
+ }
247
+
248
+ async function commitRename(id, raw) {
249
+ const title = (raw || "").trim() || "未命名";
250
+ const res = await fetch(`/api/projects/${id}`, {
251
+ method: "PATCH",
252
+ headers: { "Content-Type": "application/json" },
253
+ body: JSON.stringify({ title }),
254
+ });
255
+ renamingId = null;
256
+ if (!res.ok) {
257
+ uploadHint.textContent = await errorText(res);
258
+ loadProjects();
259
+ return;
260
+ }
261
+ const data = await res.json();
262
+ if (project && project.id === id) {
263
+ project.title = data.title;
264
+ titleInput.value = data.title;
265
+ }
266
+ loadProjects();
267
+ }
268
+
269
+ async function openProject(id) {
270
+ if (project && project.id !== id) await saveNow();
271
+ const res = await fetch(`/api/projects/${id}`);
272
+ if (!res.ok) {
273
+ uploadHint.textContent = await errorText(res);
274
+ return;
275
+ }
276
+ showEditor(await res.json());
277
+ }
278
+
279
+ function escapeHtml(text) {
280
+ return text
281
+ .replaceAll("&", "&")
282
+ .replaceAll("<", "&lt;")
283
+ .replaceAll(">", "&gt;");
284
+ }
285
+
286
+ function renderOps(ops, side) {
287
+ return ops
288
+ .map((op) => {
289
+ const t = escapeHtml(op.text);
290
+ if (op.op === "equal") return t;
291
+ if (side === "left" && op.op === "delete") return `<del>${t}</del>`;
292
+ if (side === "right" && op.op === "insert") return `<ins>${t}</ins>`;
293
+ if (side === "left" && op.op === "insert") return "";
294
+ if (side === "right" && op.op === "delete") return "";
295
+ return t;
296
+ })
297
+ .join("");
298
+ }
299
+
300
+ function fillText(el, ops, side) {
301
+ if (ops.length === 1 && ops[0].op === "equal") {
302
+ el.textContent = ops[0].text;
303
+ return;
304
+ }
305
+ el.innerHTML = renderOps(ops, side);
306
+ }
307
+
308
+ async function fetchOps(original, revised) {
309
+ if (original === revised) {
310
+ return [{ op: "equal", text: original }];
311
+ }
312
+ const res = await fetch("/api/diff", {
313
+ method: "POST",
314
+ headers: { "Content-Type": "application/json" },
315
+ body: JSON.stringify({ original, revised }),
316
+ });
317
+ const data = await res.json();
318
+ return data.ops || [{ op: "equal", text: original }];
319
+ }
320
+
321
+ function blockTag(block) {
322
+ const kind = KIND_LABEL[block.kind] || block.kind;
323
+ return [kind, block.speaker, block.episode ? `${block.episode}集` : "", block.scene]
324
+ .filter(Boolean)
325
+ .join(" · ");
326
+ }
327
+
328
+ function setEditable(el, on) {
329
+ if (!on) {
330
+ el.contentEditable = "false";
331
+ return;
332
+ }
333
+ el.contentEditable = "plaintext-only";
334
+ if (el.contentEditable !== "plaintext-only") el.contentEditable = "true";
335
+ }
336
+
337
+ function isAnnotated(block) {
338
+ return Boolean(String(block.note || "").trim() || block.score);
339
+ }
340
+
341
+ function makeAnnotate(block) {
342
+ if (block.note == null) block.note = "";
343
+ if (block.score == null) block.score = null;
344
+ const wrap = document.createElement("div");
345
+ wrap.className = "annotate";
346
+ if (isAnnotated(block)) wrap.classList.add("has-note");
347
+ const head = document.createElement("div");
348
+ head.className = "annotate-head";
349
+ head.innerHTML = "<span>批注</span><span>不是改台词</span>";
350
+ const scoreRow = document.createElement("div");
351
+ scoreRow.className = "score-row";
352
+ const label = document.createElement("span");
353
+ label.className = "score-label";
354
+ label.textContent = "打分";
355
+ const stars = document.createElement("div");
356
+ stars.className = "stars";
357
+ stars.title = "给这句话打分(再点一次取消)";
358
+ for (let n = 1; n <= 5; n += 1) {
359
+ const btn = document.createElement("button");
360
+ btn.type = "button";
361
+ btn.className = "star";
362
+ btn.textContent = "★";
363
+ if (block.score && n <= Number(block.score)) btn.classList.add("on");
364
+ btn.addEventListener("click", (e) => {
365
+ e.stopPropagation();
366
+ highlightBlock(block.id);
367
+ const next = block.score === n ? null : n;
368
+ block.score = next;
369
+ [...stars.children].forEach((el, i) => el.classList.toggle("on", next != null && i < next));
370
+ wrap.classList.toggle("has-note", isAnnotated(block));
371
+ wrap.closest(".pair")?.classList.toggle("annotated", isAnnotated(block));
372
+ setSaveState("未保存");
373
+ scheduleSave();
374
+ applyPairFilter();
375
+ });
376
+ stars.append(btn);
377
+ }
378
+ const ta = document.createElement("textarea");
379
+ ta.rows = 3;
380
+ ta.placeholder = "为什么要改?例如:太文言 / 情绪直给 / 不像这个人说话";
381
+ ta.value = block.note || "";
382
+ ta.addEventListener("input", () => {
383
+ block.note = ta.value;
384
+ wrap.classList.toggle("has-note", isAnnotated(block));
385
+ wrap.closest(".pair")?.classList.toggle("annotated", isAnnotated(block));
386
+ setSaveState("未保存");
387
+ scheduleSave();
388
+ });
389
+ ta.addEventListener("focus", () => highlightBlock(block.id));
390
+ scoreRow.append(label, stars);
391
+ wrap.append(head, scoreRow, ta);
392
+ return wrap;
393
+ }
394
+
395
+ function makePair(block, ops, index) {
396
+ const pair = document.createElement("div");
397
+ pair.className = `pair kind-${block.kind}`;
398
+ pair.dataset.id = block.id;
399
+ pair.dataset.line = String(index + 1);
400
+ if (block.original !== block.revised) pair.classList.add("changed");
401
+ if (isAnnotated(block)) pair.classList.add("annotated");
402
+ const tag = blockTag(block);
403
+
404
+ const num = document.createElement("div");
405
+ num.className = "line-no";
406
+ num.textContent = String(index + 1);
407
+ num.title = `第 ${index + 1} 行`;
408
+
409
+ const left = document.createElement("article");
410
+ left.className = `block kind-${block.kind} block-orig`;
411
+ const leftTag = document.createElement("div");
412
+ leftTag.className = "tag";
413
+ leftTag.textContent = tag;
414
+ const orig = document.createElement("div");
415
+ orig.className = "orig";
416
+ orig.contentEditable = "false";
417
+ fillText(orig, ops, "left");
418
+ left.append(leftTag, orig);
419
+
420
+ const right = document.createElement("article");
421
+ right.className = `block kind-${block.kind} block-rev`;
422
+ const rightTag = document.createElement("div");
423
+ rightTag.className = "tag";
424
+ rightTag.textContent = tag;
425
+ const rev = document.createElement("div");
426
+ rev.className = "rev";
427
+ rev.contentEditable = "false";
428
+ rev.spellcheck = false;
429
+ rev.dataset.id = block.id;
430
+ rev.setAttribute("role", "textbox");
431
+ fillText(rev, ops, "right");
432
+ rev.addEventListener("input", () => onEdit(block.id, rev));
433
+ rev.addEventListener("focus", () => highlightBlock(block.id));
434
+ right.append(rightTag, rev);
435
+
436
+ pair.addEventListener("mousedown", (e) => {
437
+ highlightBlock(block.id);
438
+ if (e.target.closest(".annotate")) return;
439
+ if (e.target.closest(".rev")) {
440
+ if (editingEl && editingEl !== rev) setEditable(editingEl, false);
441
+ setEditable(rev, true);
442
+ editingEl = rev;
443
+ }
444
+ });
445
+ pair.append(num, left, right, makeAnnotate(block));
446
+ return pair;
447
+ }
448
+
449
+ async function renderPanes() {
450
+ const gen = ++renderGen;
451
+ paneBody.innerHTML = "";
452
+ const blocks = project.blocks;
453
+ const total = blocks.length;
454
+ for (let i = 0; i < total; i += CHUNK) {
455
+ if (gen !== renderGen) return;
456
+ const frag = document.createDocumentFragment();
457
+ const slice = blocks.slice(i, i + CHUNK);
458
+ for (let j = 0; j < slice.length; j += 1) {
459
+ const block = slice[j];
460
+ const ops =
461
+ block.original === block.revised
462
+ ? [{ op: "equal", text: block.original }]
463
+ : await fetchOps(block.original, block.revised);
464
+ if (gen !== renderGen) return;
465
+ frag.append(makePair(block, ops, i + j));
466
+ }
467
+ paneBody.append(frag);
468
+ setSaveState(`加载中 ${Math.min(i + CHUNK, total)}/${total}`);
469
+ await new Promise((r) => requestAnimationFrame(r));
470
+ }
471
+ if (activeBlockId) highlightBlock(activeBlockId);
472
+ }
473
+
474
+ function highlightBlock(id) {
475
+ if (activeBlockId && activeBlockId !== id) {
476
+ const prev = paneBody.querySelector(".pair.active");
477
+ if (prev) prev.classList.remove("active");
478
+ }
479
+ const next = paneBody.querySelector(`[data-id="${CSS.escape(id)}"].pair`);
480
+ if (next) next.classList.add("active");
481
+ activeBlockId = id;
482
+ }
483
+
484
+ function onEdit(id, el) {
485
+ const block = project.blocks.find((b) => b.id === id);
486
+ if (!block) return;
487
+ highlightBlock(id);
488
+ block.revised = el.innerText.replace(/\u00a0/g, " ");
489
+ const pair = el.closest(".pair");
490
+ if (pair) pair.classList.toggle("changed", block.original !== block.revised);
491
+ setSaveState("未保存");
492
+ clearTimeout(saveTimer);
493
+ saveTimer = setTimeout(() => refreshBlock(id, el), 280);
494
+ scheduleSave();
495
+ }
496
+
497
+ async function refreshBlock(id, el) {
498
+ const block = project.blocks.find((b) => b.id === id);
499
+ if (!block) return;
500
+ const ops = await fetchOps(block.original, block.revised);
501
+ const pair = el.closest(".pair");
502
+ const orig = pair ? pair.querySelector(".orig") : null;
503
+ if (orig) fillText(orig, ops, "left");
504
+ const pos = saveCaret(el);
505
+ fillText(el, ops, "right");
506
+ restoreCaret(el, pos);
507
+ if (pair) pair.classList.toggle("changed", block.original !== block.revised);
508
+ applyPairFilter();
509
+ }
510
+
511
+ function saveCaret(el) {
512
+ const sel = window.getSelection();
513
+ if (!sel.rangeCount) return el.innerText.length;
514
+ const range = sel.getRangeAt(0);
515
+ const pre = range.cloneRange();
516
+ pre.selectNodeContents(el);
517
+ pre.setEnd(range.endContainer, range.endOffset);
518
+ return pre.toString().length;
519
+ }
520
+
521
+ function restoreCaret(el, index) {
522
+ const sel = window.getSelection();
523
+ const range = document.createRange();
524
+ let remaining = index;
525
+ const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
526
+ let node = walk.nextNode();
527
+ while (node) {
528
+ const len = node.textContent.length;
529
+ if (remaining <= len) {
530
+ range.setStart(node, remaining);
531
+ range.collapse(true);
532
+ sel.removeAllRanges();
533
+ sel.addRange(range);
534
+ return;
535
+ }
536
+ remaining -= len;
537
+ node = walk.nextNode();
538
+ }
539
+ }
540
+
541
+ function scheduleSave() {
542
+ clearTimeout(scheduleSave.t);
543
+ scheduleSave.t = setTimeout(saveNow, 1200);
544
+ }
545
+
546
+ async function saveNow() {
547
+ if (!project) return;
548
+ setSaveState("保存中…");
549
+ const res = await fetch(`/api/projects/${project.id}`, {
550
+ method: "PUT",
551
+ headers: { "Content-Type": "application/json" },
552
+ body: JSON.stringify({
553
+ title: titleInput.value,
554
+ blocks: project.blocks.map((b) => ({
555
+ id: b.id,
556
+ revised: b.revised,
557
+ note: b.note || "",
558
+ score: b.score ?? null,
559
+ })),
560
+ }),
561
+ });
562
+ if (!res.ok) {
563
+ setSaveState("保存失败");
564
+ return;
565
+ }
566
+ const data = await res.json();
567
+ project.title = data.title;
568
+ titleInput.value = data.title;
569
+ setSaveState("已保存");
570
+ if (!renamingId) loadProjects();
571
+ }
572
+
573
+ async function uploadFile(file) {
574
+ uploadHint.textContent = "";
575
+ dropLabel.textContent = "正在导入…";
576
+ $("dropZone").classList.add("busy");
577
+ setSaveState("正在导入…");
578
+ const body = new FormData();
579
+ body.append("file", file);
580
+ try {
581
+ const res = await fetch("/api/projects", { method: "POST", body });
582
+ if (!res.ok) {
583
+ uploadHint.textContent = await errorText(res);
584
+ return;
585
+ }
586
+ await showEditor(await res.json());
587
+ } catch (err) {
588
+ uploadHint.textContent = err && err.message ? err.message : "导入失败";
589
+ } finally {
590
+ dropLabel.textContent = "拖入或点击上传";
591
+ $("dropZone").classList.remove("busy");
592
+ }
593
+ }
594
+
595
+ function pairAtViewport(scroller) {
596
+ const top = scroller.scrollTop;
597
+ const anchor = top + Math.min(120, scroller.clientHeight * 0.25);
598
+ let best = null;
599
+ let bestDist = Infinity;
600
+ for (const el of scroller.children) {
601
+ if (el.classList.contains("is-hidden")) continue;
602
+ const mid = el.offsetTop + el.offsetHeight / 2;
603
+ const dist = Math.abs(mid - anchor);
604
+ if (dist < bestDist) {
605
+ bestDist = dist;
606
+ best = el;
607
+ }
608
+ }
609
+ return best;
610
+ }
611
+
612
+ function editFilter() {
613
+ const on = document.querySelector(".seg-btn.on");
614
+ return (on && on.dataset.filter) || "all";
615
+ }
616
+
617
+ function applyPairFilter() {
618
+ const q = ($("searchInput").value || "").trim().toLowerCase();
619
+ const edit = editFilter();
620
+ const noted = $("onlyAnnotated").checked;
621
+ let shown = 0;
622
+ let changed = 0;
623
+ let annotated = 0;
624
+ for (const el of paneBody.children) {
625
+ if (el.classList.contains("changed")) changed += 1;
626
+ if (el.classList.contains("annotated")) annotated += 1;
627
+ const text = el.innerText.toLowerCase();
628
+ const isChanged = el.classList.contains("changed");
629
+ const visible =
630
+ (edit === "all" || (edit === "changed" && isChanged) || (edit === "unchanged" && !isChanged)) &&
631
+ (!noted || el.classList.contains("annotated")) &&
632
+ (!q || text.includes(q));
633
+ el.classList.toggle("is-hidden", !visible);
634
+ if (visible) shown += 1;
635
+ }
636
+ const total = paneBody.children.length;
637
+ $("changeCount").textContent = `显示 ${shown}/${total} · 已改 ${changed} · 未改 ${total - changed} · 批注 ${annotated}`;
638
+ }
639
+
640
+ function gotoChanged(dir) {
641
+ const nodes = [...paneBody.querySelectorAll(".pair")].filter((el) => !el.classList.contains("is-hidden"));
642
+ if (!nodes.length) return;
643
+ const current = paneBody.querySelector(".pair.active");
644
+ let idx = current ? nodes.indexOf(current) : -1;
645
+ idx = dir > 0 ? idx + 1 : idx - 1;
646
+ if (idx < 0) idx = nodes.length - 1;
647
+ if (idx >= nodes.length) idx = 0;
648
+ const el = nodes[idx];
649
+ el.scrollIntoView({ block: "center" });
650
+ highlightBlock(el.dataset.id);
651
+ const rev = el.querySelector(".rev");
652
+ if (rev) {
653
+ if (editingEl && editingEl !== rev) setEditable(editingEl, false);
654
+ setEditable(rev, true);
655
+ editingEl = rev;
656
+ rev.focus();
657
+ }
658
+ }
659
+
660
+ function bindDropTarget(el) {
661
+ if (!el) return;
662
+ ["dragenter", "dragover"].forEach((ev) => {
663
+ el.addEventListener(ev, (e) => {
664
+ e.preventDefault();
665
+ $("dropZone").classList.add("over");
666
+ });
667
+ });
668
+ ["dragleave", "drop"].forEach((ev) => {
669
+ el.addEventListener(ev, (e) => {
670
+ e.preventDefault();
671
+ $("dropZone").classList.remove("over");
672
+ });
673
+ });
674
+ el.addEventListener("drop", (e) => {
675
+ const file = e.dataTransfer.files[0];
676
+ if (file) uploadFile(file);
677
+ });
678
+ }
679
+
680
+ let previewKind = "full";
681
+ let previewData = null;
682
+
683
+ const PREVIEW_META = {
684
+ full: {
685
+ hint: "现有完整 JSON:全部段落、字级 ops、SFT。预览只显示前 40 段,下载为完整文件。",
686
+ href: (id) => `/api/projects/${id}/export.json`,
687
+ },
688
+ trace: {
689
+ hint: "修改轨迹:只含改过的句子,记录删除/新增、批注和打分。",
690
+ href: (id) => `/api/projects/${id}/export.trace.json`,
691
+ },
692
+ sft: {
693
+ hint: "SFT JSONL:只含改过的对白,每行一条训练样本(含批注与分数)。",
694
+ href: (id) => `/api/projects/${id}/export.sft.jsonl`,
695
+ },
696
+ };
697
+
698
+ function closePreview() {
699
+ $("exportModal").hidden = true;
700
+ }
701
+
702
+ function buildLocalPreview() {
703
+ const blocks = project.blocks || [];
704
+ const fullBlocks = [];
705
+ const traces = [];
706
+ const sft = [];
707
+ let annotated = 0;
708
+ let scored = 0;
709
+ for (const b of blocks) {
710
+ const original = b.original || "";
711
+ const revised = b.revised ?? original;
712
+ const isChanged = original !== revised;
713
+ const note = b.note || "";
714
+ const score = b.score ?? null;
715
+ if (String(note).trim()) annotated += 1;
716
+ if (score) scored += 1;
717
+ const item = { ...b, original, revised, changed: isChanged, note, score, ops: [] };
718
+ fullBlocks.push(item);
719
+ if (!isChanged) continue;
720
+ traces.push({
721
+ block_id: b.id,
722
+ kind: b.kind,
723
+ speaker: b.speaker,
724
+ episode: b.episode,
725
+ scene: b.scene,
726
+ original,
727
+ revised,
728
+ ops: [],
729
+ deleted: [],
730
+ inserted: [],
731
+ note,
732
+ score,
733
+ });
734
+ if (b.kind !== "dialogue") continue;
735
+ const meta = [];
736
+ if (b.episode) meta.push(`第${b.episode}集`);
737
+ if (b.scene) meta.push(`场次 ${b.scene}`);
738
+ if (b.speaker) meta.push(`角色 ${b.speaker}`);
739
+ sft.push({
740
+ input: `${meta.join(" ")}\n【原文台词】\n${original}`.trim(),
741
+ output: revised,
742
+ block_id: b.id,
743
+ kind: b.kind,
744
+ speaker: b.speaker,
745
+ episode: b.episode,
746
+ scene: b.scene,
747
+ note,
748
+ score,
749
+ });
750
+ }
751
+ return {
752
+ stats: {
753
+ blocks: blocks.length,
754
+ changed: traces.length,
755
+ annotated,
756
+ scored,
757
+ sft: sft.length,
758
+ },
759
+ full: {
760
+ schema: "scripttrace.v1",
761
+ id: project.id,
762
+ title: project.title,
763
+ source: project.source,
764
+ blocks: fullBlocks,
765
+ sft,
766
+ },
767
+ sft,
768
+ trace: {
769
+ schema: "scripttrace.trace.v1",
770
+ id: project.id,
771
+ title: project.title,
772
+ changed_count: traces.length,
773
+ traces,
774
+ },
775
+ };
776
+ }
777
+
778
+ function extraPreviewNote(shown, total, unit) {
779
+ const rest = Math.max(0, (total ?? shown) - shown);
780
+ return rest > 0 ? `\n\n… 另有 ${rest} ${unit},请下载完整文件` : "";
781
+ }
782
+
783
+ function renderPreview() {
784
+ if (!previewData || !project) return;
785
+ const stats = previewData.stats || {};
786
+ $("previewStats").innerHTML = [
787
+ ["总段数", stats.blocks],
788
+ ["已修改", stats.changed],
789
+ ["已批注", stats.annotated],
790
+ ["已打分", stats.scored],
791
+ ["SFT 条数", stats.sft],
792
+ ]
793
+ .map(([label, value]) => `<div class="stat"><b>${value ?? 0}</b><span>${label}</span></div>`)
794
+ .join("");
795
+
796
+ document.querySelectorAll("#previewTabs .tab").forEach((el) => {
797
+ el.classList.toggle("on", el.dataset.kind === previewKind);
798
+ });
799
+ $("previewHint").textContent = PREVIEW_META[previewKind].hint;
800
+ $("btnDownload").href = PREVIEW_META[previewKind].href(project.id);
801
+
802
+ const limit = 40;
803
+ let text = "";
804
+ if (previewKind === "sft") {
805
+ const rows = previewData.sft || [];
806
+ const shown = rows.slice(0, limit);
807
+ text = shown.map((row) => JSON.stringify(row, null, 2)).join("\n\n");
808
+ text += extraPreviewNote(shown.length, stats.sft ?? rows.length, "条");
809
+ } else if (previewKind === "trace") {
810
+ const traces = previewData.trace?.traces || [];
811
+ const shown = traces.slice(0, limit);
812
+ text = JSON.stringify({ ...previewData.trace, traces: shown }, null, 2);
813
+ text += extraPreviewNote(shown.length, stats.changed ?? traces.length, "条轨迹");
814
+ } else {
815
+ const full = previewData.full || {};
816
+ const blocks = full.blocks || [];
817
+ const shown = blocks.slice(0, limit);
818
+ text = JSON.stringify(
819
+ { ...full, blocks: shown, sft: (full.sft || []).slice(0, 20) },
820
+ null,
821
+ 2,
822
+ );
823
+ text += extraPreviewNote(shown.length, stats.blocks ?? blocks.length, "段");
824
+ }
825
+ $("previewBody").textContent = text || "(暂无数据)";
826
+ }
827
+
828
+ async function openPreview() {
829
+ if (!project) return;
830
+ previewKind = "full";
831
+ previewData = buildLocalPreview();
832
+ $("exportModal").hidden = false;
833
+ renderPreview();
834
+ try {
835
+ await saveNow();
836
+ const res = await fetch(`/api/projects/${project.id}/preview`);
837
+ if (!res.ok) {
838
+ $("previewHint").textContent =
839
+ `${PREVIEW_META[previewKind].hint} 服务端预览暂不可用(${await errorText(res)}),当前为本地预览。下载前请确认已重启服务。`;
840
+ return;
841
+ }
842
+ previewData = await res.json();
843
+ renderPreview();
844
+ } catch (err) {
845
+ $("previewHint").textContent =
846
+ `${PREVIEW_META[previewKind].hint} 预览刷新失败:${err && err.message ? err.message : err}`;
847
+ }
848
+ }
849
+
850
+ paneBody.addEventListener(
851
+ "scroll",
852
+ () => {
853
+ const focused = document.activeElement;
854
+ if (focused && focused.classList.contains("rev")) return;
855
+ const el = pairAtViewport(paneBody);
856
+ if (el) highlightBlock(el.dataset.id);
857
+ },
858
+ { passive: true },
859
+ );
860
+
861
+ paneBody.addEventListener("beforeinput", (e) => {
862
+ if (e.target.closest(".orig")) e.preventDefault();
863
+ });
864
+ paneBody.addEventListener("paste", (e) => {
865
+ if (e.target.closest(".orig")) e.preventDefault();
866
+ });
867
+ paneBody.addEventListener("drop", (e) => {
868
+ if (e.target.closest(".orig")) e.preventDefault();
869
+ });
870
+
871
+ function on(id, ev, fn) {
872
+ const el = typeof id === "string" ? $(id) : id;
873
+ if (el) el.addEventListener(ev, fn);
874
+ }
875
+
876
+ btnSidebar.addEventListener("click", (e) => {
877
+ e.stopPropagation();
878
+ toggleSidebar();
879
+ });
880
+ on("btnSidebarBar", "click", toggleSidebar);
881
+ on("btnEmptySidebar", "click", (e) => {
882
+ e.stopPropagation();
883
+ applySidebar(false);
884
+ });
885
+ on("btnEmptyImport", "click", (e) => {
886
+ e.stopPropagation();
887
+ $("fileInput").click();
888
+ });
889
+ on("emptyState", "click", (e) => {
890
+ if (e.target.closest("button")) return;
891
+ $("fileInput").click();
892
+ });
893
+ on("sidebar", "click", () => {
894
+ if (isSidebarCollapsed()) applySidebar(false);
895
+ });
896
+ on("btnSave", "click", saveNow);
897
+ titleInput.addEventListener("input", () => {
898
+ setSaveState("未保存");
899
+ scheduleSave();
900
+ });
901
+ titleInput.addEventListener("change", () => saveNow());
902
+ on("searchInput", "input", applyPairFilter);
903
+ on("onlyAnnotated", "change", applyPairFilter);
904
+ on(document.querySelector(".seg"), "click", (e) => {
905
+ const btn = e.target.closest(".seg-btn");
906
+ if (!btn) return;
907
+ document.querySelectorAll(".seg-btn").forEach((el) => el.classList.toggle("on", el === btn));
908
+ applyPairFilter();
909
+ });
910
+ on("btnPrevChange", "click", () => gotoChanged(-1));
911
+ on("btnNextChange", "click", () => gotoChanged(1));
912
+ on("btnPreview", "click", openPreview);
913
+ on("btnClosePreview", "click", closePreview);
914
+ on("exportModal", "click", (e) => {
915
+ if (e.target.id === "exportModal") closePreview();
916
+ });
917
+ on("previewTabs", "click", (e) => {
918
+ const tab = e.target.closest(".tab");
919
+ if (!tab) return;
920
+ previewKind = tab.dataset.kind;
921
+ renderPreview();
922
+ });
923
+ on("btnDownload", "click", async (e) => {
924
+ if (!project) return;
925
+ e.preventDefault();
926
+ const href = PREVIEW_META[previewKind].href(project.id);
927
+ try {
928
+ await saveNow();
929
+ } catch {
930
+ /* still download current server copy */
931
+ }
932
+ window.location.href = href;
933
+ });
934
+
935
+ document.addEventListener("keydown", (e) => {
936
+ if (e.key === "Escape" && !$("exportModal").hidden) {
937
+ e.preventDefault();
938
+ closePreview();
939
+ return;
940
+ }
941
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
942
+ e.preventDefault();
943
+ saveNow();
944
+ }
945
+ if ((e.ctrlKey || e.metaKey) && e.key === "ArrowDown") {
946
+ e.preventDefault();
947
+ gotoChanged(1);
948
+ }
949
+ if ((e.ctrlKey || e.metaKey) && e.key === "ArrowUp") {
950
+ e.preventDefault();
951
+ gotoChanged(-1);
952
+ }
953
+ });
954
+ window.addEventListener("beforeunload", (e) => {
955
+ if (!isDirty()) return;
956
+ e.preventDefault();
957
+ e.returnValue = "";
958
+ });
959
+
960
+ const dropZone = $("dropZone");
961
+ const fileInput = $("fileInput");
962
+ fileInput.addEventListener("change", () => {
963
+ if (fileInput.files[0]) uploadFile(fileInput.files[0]);
964
+ fileInput.value = "";
965
+ });
966
+ bindDropTarget(dropZone);
967
+ bindDropTarget($("emptyState"));
968
+ bindDropTarget($("workspace"));
969
+
970
+ initSidebar();
971
+ loadProjects();