Files
2026-07-06 20:43:21 +08:00

888 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
let rows = [];
let filteredRows = [];
let structure = { chapters: [] };
let currentIndex = -1;
let currentRow = null;
let currentChapter = null;
let currentPart = null;
let currentMode = "reading";
let showTouchedOnly = false;
let dirty = false;
let quickDirty = false;
const $ = (id) => document.getElementById(id);
const dom = {
sessionMeta: $("sessionMeta"),
readingModeBtn: $("readingModeBtn"),
polishModeBtn: $("polishModeBtn"),
showTouchedBtn: $("showTouchedBtn"),
feedbackBtn: $("feedbackBtn"),
applyBtn: $("applyBtn"),
exportBtn: $("exportBtn"),
searchInput: $("searchInput"),
stats: $("stats"),
toc: $("toc"),
rowList: $("rowList"),
readerPane: $("readerPane"),
editorPane: $("editorPane"),
chapterKicker: $("chapterKicker"),
readerTitle: $("readerTitle"),
readingFlow: $("readingFlow"),
prevPartBtn: $("prevPartBtn"),
nextPartBtn: $("nextPartBtn"),
emptyState: $("emptyState"),
editorCard: $("editorCard"),
rowId: $("rowId"),
rowPath: $("rowPath"),
markedInput: $("markedInput"),
jpText: $("jpText"),
cnEditor: $("cnEditor"),
markSelectionBtn: $("markSelectionBtn"),
clearMarksBtn: $("clearMarksBtn"),
resetTextBtn: $("resetTextBtn"),
issueTypeInput: $("issueTypeInput"),
severityInput: $("severityInput"),
tagsInput: $("tagsInput"),
commentInput: $("commentInput"),
learnNoteInput: $("learnNoteInput"),
prevBtn: $("prevBtn"),
saveBtn: $("saveBtn"),
nextBtn: $("nextBtn"),
quickEditor: $("quickEditor"),
quickRowId: $("quickRowId"),
quickRowPath: $("quickRowPath"),
quickJpText: $("quickJpText"),
quickCnEditor: $("quickCnEditor"),
quickMarkedInput: $("quickMarkedInput"),
quickIssueTypeInput: $("quickIssueTypeInput"),
quickSeverityInput: $("quickSeverityInput"),
quickTagsInput: $("quickTagsInput"),
quickCommentInput: $("quickCommentInput"),
quickLearnNoteInput: $("quickLearnNoteInput"),
closeQuickEditorBtn: $("closeQuickEditorBtn"),
quickMarkSelectionBtn: $("quickMarkSelectionBtn"),
quickSaveBtn: $("quickSaveBtn"),
quickOpenFullBtn: $("quickOpenFullBtn"),
toast: $("toast"),
};
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function stripTags(value) {
const div = document.createElement("div");
div.innerHTML = value || "";
return div.textContent || div.innerText || "";
}
function toast(message, ms = 3600) {
dom.toast.textContent = message;
dom.toast.hidden = false;
clearTimeout(toast.timer);
toast.timer = setTimeout(() => {
dom.toast.hidden = true;
}, ms);
}
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
...options,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
return data;
}
function rowTouched(row) {
return Boolean(row.edited || row.marked || row.comment || row.learn_note || row.issue_type || row.severity || row.tags);
}
function rowCurrentText(row) {
return stripTags(row.current_html || row.cn_html || row.cn_text || "");
}
function rowById(rowId) {
return rows.find((item) => item.id === rowId) || null;
}
function allParts() {
return (structure.chapters || []).flatMap((chapter) => chapter.parts || []);
}
function allChapterScopes() {
return (structure.chapters || []).map((chapter) => ({
type: "chapter",
id: chapter.id,
chapter,
}));
}
function partById(partId) {
return allParts().find((part) => part.id === partId) || null;
}
function chapterById(chapterId) {
return (structure.chapters || []).find((chapter) => chapter.id === chapterId) || null;
}
function partRows(part) {
if (!part) return [];
const ids = new Set(part.row_ids || []);
return rows.filter((row) => ids.has(row.id));
}
function partForRow(row) {
if (!row) return null;
return allParts().find((part) => (part.row_ids || []).includes(row.id)) || null;
}
function chapterForPart(part) {
if (!part) return null;
return (structure.chapters || []).find((chapter) => (chapter.parts || []).some((item) => item.id === part.id)) || null;
}
function chapterRows(chapter) {
if (!chapter) return [];
const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || []));
return rows.filter((row) => ids.has(row.id));
}
function partVisibleRows(part) {
const ids = new Set((part && part.row_ids) || []);
return filteredRows.filter((row) => ids.has(row.id));
}
function chapterVisibleRows(chapter) {
if (!chapter) return [];
const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || []));
return filteredRows.filter((row) => ids.has(row.id));
}
function scopeRows() {
if (currentPart) return partRows(currentPart);
if (currentChapter) return chapterRows(currentChapter);
return rows;
}
function scopeVisibleRows() {
if (currentPart) return partVisibleRows(currentPart);
if (currentChapter) return chapterVisibleRows(currentChapter);
return filteredRows;
}
async function loadSession() {
const session = await api("/api/session");
const chapterCount = structure.chapter_count || (structure.chapters || []).length;
const partCount = structure.part_count || allParts().length;
dom.sessionMeta.textContent = `${session.source_epub} · ${session.row_count} 段 · ${chapterCount} 章 / ${partCount} part · 已记录 ${session.touched_count} 段`;
}
async function loadStructure() {
structure = await api("/api/structure");
}
async function loadRows(keepSelection = false) {
const oldId = currentRow && currentRow.id;
const payload = await api("/api/rows");
rows = payload.rows || [];
applyFilter(false);
if (keepSelection && oldId) {
const nextRow = rowById(oldId);
if (nextRow) {
currentRow = nextRow;
currentIndex = filteredRows.findIndex((row) => row.id === nextRow.id);
if (currentPart && !(currentPart.row_ids || []).includes(nextRow.id)) {
currentPart = partForRow(nextRow);
}
renderAll();
return;
}
}
if (!currentPart) {
currentChapter = (structure.chapters || [])[0] || null;
currentPart = currentChapter && (currentChapter.parts || []).length === 1 ? currentChapter.parts[0] : null;
}
if (filteredRows.length > 0 && currentIndex < 0) {
currentRow = filteredRows[0];
currentIndex = 0;
}
renderAll();
}
function updateStructureCountsFromRows() {
for (const chapter of structure.chapters || []) {
chapter.row_count = 0;
chapter.touched_count = 0;
chapter.marked_count = 0;
for (const part of chapter.parts || []) {
const partItems = partRows(part);
part.row_count = partItems.length;
part.touched_count = partItems.filter(rowTouched).length;
part.marked_count = partItems.filter((row) => row.marked).length;
chapter.row_count += part.row_count;
chapter.touched_count += part.touched_count;
chapter.marked_count += part.marked_count;
}
}
}
function rowMatchesSearch(row, q) {
if (showTouchedOnly && !rowTouched(row)) return false;
if (!q) return true;
const haystack = [
row.id,
row.file_label,
row.document_title,
row.jp_text,
row.cn_text,
rowCurrentText(row),
row.comment,
row.learn_note,
row.tags,
row.issue_type,
]
.join("\n")
.toLowerCase();
return haystack.includes(q);
}
function applyFilter(shouldRender = true) {
const q = dom.searchInput.value.trim().toLowerCase();
filteredRows = rows.filter((row) => rowMatchesSearch(row, q));
if (currentRow) {
currentIndex = filteredRows.findIndex((row) => row.id === currentRow.id);
}
if (shouldRender) renderAll();
}
function renderAll() {
updateStructureCountsFromRows();
renderToc();
renderList();
renderMode();
if (currentMode === "reading") {
renderReading();
} else {
renderEditor();
}
}
function renderMode() {
dom.readerPane.hidden = currentMode !== "reading";
dom.editorPane.hidden = currentMode !== "polish";
dom.readingModeBtn.classList.toggle("active", currentMode === "reading");
dom.polishModeBtn.classList.toggle("active", currentMode === "polish");
document.body.classList.toggle("quickOpen", currentMode === "reading" && !dom.quickEditor.hidden);
}
function renderToc() {
dom.toc.innerHTML = "";
const frag = document.createDocumentFragment();
for (const chapter of structure.chapters || []) {
const group = document.createElement("section");
group.className = "tocChapter";
const title = document.createElement("button");
title.type = "button";
title.className = "tocChapterButton";
if (
(currentChapter && chapter.id === currentChapter.id && !currentPart)
|| (chapter.parts || []).some((part) => currentPart && part.id === currentPart.id)
) {
title.classList.add("active");
}
title.innerHTML = `
<span class="tocTitle">${escapeHtml(chapter.title || chapter.id)}</span>
<span class="tocCount">${chapter.row_count || 0} 段</span>
`;
title.addEventListener("click", () => selectChapter(chapter));
group.appendChild(title);
const partList = document.createElement("div");
partList.className = "tocParts";
for (const part of chapter.parts || []) {
const button = document.createElement("button");
button.type = "button";
button.className = "tocPartButton";
if (currentPart && part.id === currentPart.id) button.classList.add("active");
const badges = [];
if (part.touched_count) badges.push(`记 ${part.touched_count}`);
if (part.marked_count) badges.push(`标 ${part.marked_count}`);
button.innerHTML = `
<span class="tocTitle">${escapeHtml(part.title || part.file_label || part.id)}</span>
<span class="tocCount">${part.row_count || 0}${badges.length ? " · " + escapeHtml(badges.join(" · ")) : ""}</span>
`;
button.addEventListener("click", () => selectPart(part));
partList.appendChild(button);
}
group.appendChild(partList);
frag.appendChild(group);
}
dom.toc.appendChild(frag);
}
function renderList() {
const touched = rows.filter(rowTouched).length;
const marked = rows.filter((row) => row.marked).length;
const visibleInScope = scopeVisibleRows();
dom.stats.textContent = `显示 ${filteredRows.length} / ${rows.length} 段 · 当前 ${visibleInScope.length} 段 · 已记录 ${touched} · 标记 ${marked}`;
dom.rowList.innerHTML = "";
const frag = document.createDocumentFragment();
visibleInScope.forEach((row) => {
const button = document.createElement("button");
button.type = "button";
button.className = "rowItem";
if (currentRow && row.id === currentRow.id) button.classList.add("active");
if (rowTouched(row)) button.classList.add("touched");
if (row.marked) button.classList.add("marked");
const badge = row.marked ? "已标记" : row.edited ? "已编辑" : row.comment ? "有备注" : "未记录";
button.innerHTML = `
<div class="rowMeta">
<span>${escapeHtml(row.id)}</span>
<span class="rowBadge">${escapeHtml(badge)}</span>
<span>${escapeHtml(row.file_label)} · #${row.file_row_index || row.cn_p_index}</span>
</div>
<div class="rowPreview">${escapeHtml(rowCurrentText(row))}</div>
`;
button.addEventListener("click", () => maybeSelectRow(row, { openEditor: currentMode === "polish", scroll: true }));
frag.appendChild(button);
});
dom.rowList.appendChild(frag);
}
function renderReading() {
const selectedChapter = currentChapter || chapterForPart(currentPart);
if (!currentPart && !selectedChapter) {
dom.chapterKicker.textContent = "目录";
dom.readerTitle.textContent = "选择章节开始阅读";
dom.readingFlow.innerHTML = '<div class="emptyState">没有可阅读段落。</div>';
return;
}
const selectedPart = currentPart;
dom.chapterKicker.textContent = selectedChapter ? selectedChapter.title : selectedPart.file_label || "";
dom.readerTitle.textContent = selectedPart
? selectedPart.title || selectedPart.file_label || "未命名 part"
: selectedChapter.title || "未命名章节";
dom.readingFlow.innerHTML = "";
const visibleRows = scopeVisibleRows();
const sourceRows = visibleRows.length || dom.searchInput.value || showTouchedOnly ? visibleRows : scopeRows();
if (!sourceRows.length) {
dom.readingFlow.innerHTML = '<div class="emptyState">当前筛选条件下没有段落。</div>';
return;
}
const frag = document.createDocumentFragment();
let lastFile = "";
sourceRows.forEach((row) => {
if (!selectedPart && row.file !== lastFile) {
const part = partForRow(row);
if (part) {
const divider = document.createElement("h3");
divider.className = "partDivider";
divider.textContent = part.title || part.file_label || row.file;
frag.appendChild(divider);
}
lastFile = row.file;
}
const block = document.createElement("section");
block.className = "readBlock";
block.dataset.rowId = row.id;
if (currentRow && row.id === currentRow.id) block.classList.add("active");
if (rowTouched(row)) block.classList.add("touched");
if (row.marked) block.classList.add("marked");
const status = row.marked ? "已标记" : row.edited ? "已编辑" : row.comment ? "有备注" : "";
block.innerHTML = `
<div class="readText">
<div class="readSource">${row.jp_html || escapeHtml(row.jp_text)}</div>
<div class="readCn">${row.current_html || row.cn_html || escapeHtml(row.cn_text)}</div>
</div>
<div class="readMeta">
<span>${escapeHtml(row.id)} · #${row.file_row_index || row.cn_p_index}</span>
${status ? `<span class="readStatus">${escapeHtml(status)}</span>` : ""}
<button class="miniButton" type="button" data-action="quick">快速编辑</button>
<button class="miniButton" type="button" data-action="mark">标记问题</button>
</div>
`;
block.addEventListener("click", (event) => {
const action = event.target && event.target.dataset && event.target.dataset.action;
if (action === "quick") {
event.stopPropagation();
openQuickEditor(row);
return;
}
if (action === "mark") {
event.stopPropagation();
markRowFromReading(row);
return;
}
maybeSelectRow(row, { openQuick: true, scroll: false });
});
frag.appendChild(block);
});
dom.readingFlow.appendChild(frag);
}
function renderEditor() {
if (!currentRow) {
dom.emptyState.hidden = false;
dom.editorCard.hidden = true;
return;
}
dom.emptyState.hidden = true;
dom.editorCard.hidden = false;
fillFullEditor(currentRow);
}
function fillFullEditor(row) {
dom.rowId.textContent = row.id;
dom.rowPath.textContent = `${row.file} · 日文 P${row.ja_p_index} / 中文 P${row.cn_p_index}`;
dom.markedInput.checked = Boolean(row.marked);
dom.jpText.innerHTML = row.jp_html || escapeHtml(row.jp_text);
dom.cnEditor.innerHTML = row.current_html || row.cn_html || "";
dom.issueTypeInput.value = row.issue_type || "";
dom.severityInput.value = row.severity || "";
dom.tagsInput.value = row.tags || "";
dom.commentInput.value = row.comment || "";
dom.learnNoteInput.value = row.learn_note || "";
}
function fillQuickEditor(row) {
dom.quickRowId.textContent = row.id;
dom.quickRowPath.textContent = `${row.file_label} · #${row.file_row_index || row.cn_p_index}`;
dom.quickJpText.innerHTML = row.jp_html || escapeHtml(row.jp_text);
dom.quickCnEditor.innerHTML = row.current_html || row.cn_html || "";
dom.quickMarkedInput.checked = Boolean(row.marked);
dom.quickIssueTypeInput.value = row.issue_type || "";
dom.quickSeverityInput.value = row.severity || "";
dom.quickTagsInput.value = row.tags || "";
dom.quickCommentInput.value = row.comment || "";
dom.quickLearnNoteInput.value = row.learn_note || "";
quickDirty = false;
}
function collectCurrent() {
if (!currentRow) return null;
if (currentMode === "reading" && !dom.quickEditor.hidden) {
return collectQuick();
}
return collectFull();
}
function collectFull() {
return {
current_html: dom.cnEditor.innerHTML,
marked: dom.markedInput.checked,
issue_type: dom.issueTypeInput.value,
severity: dom.severityInput.value,
tags: dom.tagsInput.value,
comment: dom.commentInput.value,
learn_note: dom.learnNoteInput.value,
};
}
function collectQuick() {
return {
current_html: dom.quickCnEditor.innerHTML,
marked: dom.quickMarkedInput.checked,
issue_type: dom.quickIssueTypeInput.value,
severity: dom.quickSeverityInput.value,
tags: dom.quickTagsInput.value,
comment: dom.quickCommentInput.value,
learn_note: dom.quickLearnNoteInput.value,
};
}
function applyRowData(row, data, updatedAt = "") {
row.current_html = data.current_html;
row.marked = data.marked;
row.issue_type = data.issue_type;
row.severity = data.severity;
row.tags = data.tags;
row.comment = data.comment;
row.learn_note = data.learn_note;
row.edited = data.current_html !== row.cn_html;
row.updated_at = updatedAt || row.updated_at || "";
}
async function saveRow(row, data, silent = false) {
if (!row) return true;
try {
const result = await api(`/api/row/${encodeURIComponent(row.id)}`, {
method: "POST",
body: JSON.stringify(data),
});
const target = rowById(row.id);
if (target) applyRowData(target, data, result.updated_at);
currentRow = target || row;
dirty = false;
quickDirty = false;
applyFilter(false);
renderAll();
await loadSession();
if (!silent) toast("已保存本段记录。");
return true;
} catch (error) {
toast(`保存失败:${error.message}`, 7000);
return false;
}
}
async function saveCurrent(silent = false) {
if (!currentRow) return true;
return saveRow(currentRow, collectCurrent(), silent);
}
async function maybeSaveBeforeSwitch() {
if (currentMode === "reading" && quickDirty && currentRow && !dom.quickEditor.hidden) {
return saveCurrent(true);
}
if (currentMode === "polish" && dirty && currentRow) {
return saveCurrent(true);
}
return true;
}
async function maybeSelectRow(row, options = {}) {
const ok = await maybeSaveBeforeSwitch();
if (!ok) return;
selectRow(row, options);
}
function selectRow(row, options = {}) {
if (!row) return;
currentRow = row;
currentIndex = filteredRows.findIndex((item) => item.id === row.id);
const nextPart = partForRow(row);
if (nextPart) {
currentChapter = chapterForPart(nextPart);
currentPart = nextPart;
}
dirty = false;
if (options.openEditor) {
setMode("polish", false);
} else if (options.openQuick) {
openQuickEditor(row);
} else {
renderAll();
}
if (options.scroll) scrollRowIntoView(row.id);
}
async function selectChapter(chapter) {
if (!chapter) return;
const ok = await maybeSaveBeforeSwitch();
if (!ok) return;
currentChapter = chapter;
currentPart = (chapter.parts || []).length === 1 ? chapter.parts[0] : null;
const visible = scopeVisibleRows();
const fallback = scopeRows();
currentRow = visible[0] || fallback[0] || currentRow;
currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1;
renderAll();
if (currentMode === "reading") {
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
}
}
async function selectPart(part) {
if (!part) return;
const ok = await maybeSaveBeforeSwitch();
if (!ok) return;
currentChapter = chapterForPart(part);
currentPart = part;
const visible = partVisibleRows(part);
const fallback = partRows(part);
currentRow = visible[0] || fallback[0] || currentRow;
currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1;
renderAll();
if (currentMode === "reading") {
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
}
}
function scrollRowIntoView(rowId) {
if (currentMode === "reading") {
requestAnimationFrame(() => {
const target = dom.readingFlow.querySelector(`[data-row-id="${CSS.escape(rowId)}"]`);
if (target) target.scrollIntoView({ block: "center", behavior: "smooth" });
});
}
}
async function setMode(mode, saveBefore = true) {
if (saveBefore) {
const ok = await maybeSaveBeforeSwitch();
if (!ok) return;
}
currentMode = mode;
if (mode === "polish") {
closeQuickEditor(false);
if (!currentRow && filteredRows.length) currentRow = filteredRows[0];
}
renderAll();
if (mode === "reading" && currentRow) scrollRowIntoView(currentRow.id);
}
function openQuickEditor(row) {
currentRow = row;
currentIndex = filteredRows.findIndex((item) => item.id === row.id);
const nextPart = partForRow(row);
if (nextPart) {
currentChapter = chapterForPart(nextPart);
currentPart = nextPart;
}
fillQuickEditor(row);
dom.quickEditor.hidden = false;
document.body.classList.add("quickOpen");
renderAll();
}
function closeQuickEditor(save = false) {
if (save && quickDirty) {
saveCurrent(true);
return;
}
dom.quickEditor.hidden = true;
document.body.classList.remove("quickOpen");
quickDirty = false;
}
async function markRowFromReading(row) {
const ok = await maybeSaveBeforeSwitch();
if (!ok) return;
const data = {
current_html: row.current_html || row.cn_html || "",
marked: true,
issue_type: row.issue_type || "翻译腔",
severity: row.severity || "",
tags: row.tags || "",
comment: row.comment || "",
learn_note: row.learn_note || "",
};
await saveRow(row, data, false);
openQuickEditor(rowById(row.id) || row);
}
function markDirty() {
dirty = true;
}
function markQuickDirty() {
quickDirty = true;
}
function resetCurrentText() {
if (!currentRow) return;
dom.cnEditor.innerHTML = currentRow.cn_html || "";
markDirty();
}
function markSelectionInEditor(editor, markedInput, issueInput) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
toast("请先在中文译文中选中要标记的文字。");
return false;
}
const range = selection.getRangeAt(0);
if (!editor.contains(range.commonAncestorContainer)) {
toast("只能标记中文译文区域内的文字。");
return false;
}
const mark = document.createElement("mark");
mark.className = "review-mark";
mark.setAttribute("data-review-mark", "bad-translation");
try {
range.surroundContents(mark);
} catch (_error) {
const fragment = range.extractContents();
mark.appendChild(fragment);
range.insertNode(mark);
}
selection.removeAllRanges();
markedInput.checked = true;
if (!issueInput.value) issueInput.value = "翻译腔";
return true;
}
function markSelection() {
if (markSelectionInEditor(dom.cnEditor, dom.markedInput, dom.issueTypeInput)) markDirty();
}
function markQuickSelection() {
if (markSelectionInEditor(dom.quickCnEditor, dom.quickMarkedInput, dom.quickIssueTypeInput)) markQuickDirty();
}
function clearMarks() {
dom.cnEditor.querySelectorAll("mark, .review-mark, [data-review-mark]").forEach((node) => {
const parent = node.parentNode;
while (node.firstChild) parent.insertBefore(node.firstChild, node);
parent.removeChild(node);
parent.normalize();
});
markDirty();
}
async function go(delta) {
if (!filteredRows.length) return;
const index = currentIndex >= 0 ? currentIndex : filteredRows.findIndex((row) => currentRow && row.id === currentRow.id);
const next = Math.max(0, Math.min(filteredRows.length - 1, index + delta));
if (next === index) return;
await maybeSelectRow(filteredRows[next], { openEditor: currentMode === "polish", openQuick: currentMode === "reading" && !dom.quickEditor.hidden, scroll: true });
}
async function goPart(delta) {
const scopes = allChapterScopes();
if (!scopes.length) return;
const currentScopeId = currentChapter ? currentChapter.id : chapterForPart(currentPart)?.id;
const index = Math.max(0, scopes.findIndex((scope) => scope.id === currentScopeId));
const next = Math.max(0, Math.min(scopes.length - 1, index + delta));
if (next === index) return;
await selectChapter(scopes[next].chapter);
}
async function generateFeedback() {
if (!(await maybeSaveBeforeSwitch())) return;
const data = await api("/api/feedback");
toast(`已生成反馈文件:\n${data.feedback_md}\n${data.feedback_jsonl}`, 9000);
}
async function applyBack() {
if (!(await maybeSaveBeforeSwitch())) return;
const data = await api("/api/apply", { method: "POST", body: "{}" });
toast(`已写回 EPUB 解包内容。\n修改文件数:${data.modified_files.length}\n反馈:${data.feedback_md}`, 9000);
}
async function exportEpub() {
if (!(await maybeSaveBeforeSwitch())) return;
const data = await api("/api/export", { method: "POST", body: "{}" });
toast(`已导出审校 EPUB\n${data.output}`, 10000);
}
function initEvents() {
[
dom.markedInput,
dom.cnEditor,
dom.issueTypeInput,
dom.severityInput,
dom.tagsInput,
dom.commentInput,
dom.learnNoteInput,
].forEach((el) => {
el.addEventListener("input", markDirty);
el.addEventListener("change", markDirty);
});
[
dom.quickMarkedInput,
dom.quickCnEditor,
dom.quickIssueTypeInput,
dom.quickSeverityInput,
dom.quickTagsInput,
dom.quickCommentInput,
dom.quickLearnNoteInput,
].forEach((el) => {
el.addEventListener("input", markQuickDirty);
el.addEventListener("change", markQuickDirty);
});
dom.searchInput.addEventListener("input", () => {
currentIndex = -1;
applyFilter(true);
});
dom.showTouchedBtn.addEventListener("click", () => {
showTouchedOnly = !showTouchedOnly;
dom.showTouchedBtn.classList.toggle("primary", showTouchedOnly);
applyFilter(true);
});
dom.readingModeBtn.addEventListener("click", () => setMode("reading"));
dom.polishModeBtn.addEventListener("click", () => setMode("polish"));
dom.prevPartBtn.addEventListener("click", () => goPart(-1));
dom.nextPartBtn.addEventListener("click", () => goPart(1));
dom.resetTextBtn.addEventListener("click", resetCurrentText);
dom.markSelectionBtn.addEventListener("click", markSelection);
dom.clearMarksBtn.addEventListener("click", clearMarks);
dom.saveBtn.addEventListener("click", () => saveCurrent(false));
dom.prevBtn.addEventListener("click", () => go(-1));
dom.nextBtn.addEventListener("click", () => go(1));
dom.feedbackBtn.addEventListener("click", generateFeedback);
dom.applyBtn.addEventListener("click", applyBack);
dom.exportBtn.addEventListener("click", exportEpub);
dom.closeQuickEditorBtn.addEventListener("click", () => closeQuickEditor(false));
dom.quickSaveBtn.addEventListener("click", () => saveCurrent(false));
dom.quickMarkSelectionBtn.addEventListener("click", markQuickSelection);
dom.quickOpenFullBtn.addEventListener("click", async () => {
if (quickDirty) {
const ok = await saveCurrent(true);
if (!ok) return;
}
await setMode("polish", false);
});
document.addEventListener("keydown", async (event) => {
if (event.ctrlKey && event.key.toLowerCase() === "s") {
event.preventDefault();
await saveCurrent(false);
return;
}
if (event.target && ["INPUT", "TEXTAREA"].includes(event.target.tagName)) return;
if (event.altKey && event.key === "ArrowLeft") {
event.preventDefault();
go(-1);
}
if (event.altKey && event.key === "ArrowRight") {
event.preventDefault();
go(1);
}
if (event.altKey && event.key === "ArrowUp") {
event.preventDefault();
goPart(-1);
}
if (event.altKey && event.key === "ArrowDown") {
event.preventDefault();
goPart(1);
}
if (event.key === "Escape" && !dom.quickEditor.hidden) {
event.preventDefault();
closeQuickEditor(false);
}
});
window.addEventListener("beforeunload", (event) => {
if (!dirty && !quickDirty) return;
event.preventDefault();
event.returnValue = "";
});
}
async function main() {
initEvents();
try {
await loadStructure();
await loadRows();
await loadSession();
toast("阅读模式已就绪。可连续阅读,点击段落即可快速编辑或标记。", 4200);
} catch (error) {
toast(`启动失败:${error.message}`, 10000);
}
}
main();