888 lines
28 KiB
JavaScript
888 lines
28 KiB
JavaScript
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("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """);
|
||
}
|
||
|
||
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();
|