Initial bilingual EPUB review editor

This commit is contained in:
xin61
2026-07-06 20:43:21 +08:00
commit bdbc926b78
7 changed files with 2925 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# This repository is scoped to the bilingual EPUB review editor.
# Keep translation corpora, EPUBs, review sessions, and generated outputs out of Git.
*
!.gitignore
!tools/
tools/*
!tools/epub_review_editor/
!tools/epub_review_editor/**
# Editor runtime/cache artifacts
tools/epub_review_editor/**/__pycache__/
tools/epub_review_editor/**/*.py[cod]
tools/epub_review_editor/.pytest_cache/
tools/epub_review_editor/.ruff_cache/
tools/epub_review_editor/.mypy_cache/
tools/epub_review_editor/.venv/
tools/epub_review_editor/venv/
tools/epub_review_editor/epub_review_sessions/
tools/epub_review_editor/review_sessions/
tools/epub_review_editor/**/*.log
tools/epub_review_editor/**/*.epub
+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.mypy_cache/
.venv/
venv/
epub_review_sessions/
review_sessions/
*.log
*.epub
+60
View File
@@ -0,0 +1,60 @@
# 双语 EPUB 审校编辑器
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
## 启动
```powershell
$env:PYTHONIOENCODING='utf-8'
python 'C:\Users\xin61\Documents\轻小说翻译持续改进\tools\epub_review_editor\server.py' '<中日双语版.epub>' --daemon
```
默认会自动打开浏览器。如果没有自动打开,命令会打印 URL,例如:
```text
http://localhost:5177
```
## 能做什么
- 阅读模式下按章节连续阅读日文原文与中文译文,不需要逐句点击。
- 左侧目录按 EPUB nav/toc 或 spine 文件顺序组织章节;章节下显示内部 part,点击 part 后只看该 part,点击章节则看整章连续正文。
- 在阅读模式中点击段落即可打开快速审校面板,直接编辑中文译文或标记问题。
- 精修模式保留原有逐段编辑界面,用于较细的逐段复审。
- 直接编辑中文译文。
- 标记问题段落或选中文本。
- 记录问题类型、严重程度、标签、备注和可沉淀的长期规则。
- 生成反馈文件,供 Codex 后续持续优化翻译和润色质量。
- 将编辑写回 EPUB 解包内容。
- 导出一份新的“网页审校版 EPUB”。
## 重要输出
每个 EPUB 会建立独立审校 session,默认位于:
```text
epub_review_sessions\<epub-name>_<hash>\
```
关键文件:
- `feedback/translation_feedback.jsonl`:逐次保存的结构化反馈记录。
- `feedback/feedback_for_codex.md`:适合直接给 Codex 阅读的审校反馈摘要。
- `exports/*_网页审校版_*.epub`:导出的修订版 EPUB。
- `review_state/state.json`:网页编辑器当前状态。
- `extracted/`EPUB 解包后的工作副本。
## 使用建议
- 正式长篇 EPUB 优先使用“阅读模式”:先在左侧点章节/part 连续读,看到问题再点段落快速编辑或标记。
- 需要集中逐段复审时切到“精修模式”,左侧段落列表会跟随当前章节筛选。
- 只想记录问题时:勾选“标记为翻译问题”,写备注,保存本段。
- 想直接修正译文时:编辑中文译文后保存本段。
- 想让 Codex 学习你的偏好时:把“可沉淀为长期规则”写具体,例如“Falchion 固定译为大砍刀,不要译成偃月刀”。
- 审校结束后点击“生成反馈”,再在聊天里告诉 Codex 读取 `feedback_for_codex.md``translation_feedback.jsonl`
## 当前限制
- 当前解析规则针对本项目的“日文灰字 + 中文紧随其后”的双语 EPUB。
- 目录用于审校导航,不负责可视化修改 EPUB 的图片、目录和 OPF 元数据。
- 浏览器内保存的是审校工作副本;要生成 EPUB 文件,需要点击“导出审校 EPUB”。
File diff suppressed because it is too large Load Diff
+887
View File
@@ -0,0 +1,887 @@
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("&", "&amp;")
.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();
+195
View File
@@ -0,0 +1,195 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>双语 EPUB 审校编辑器</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="topbar">
<div class="brandBlock">
<h1>双语 EPUB 审校编辑器</h1>
<p id="sessionMeta">正在载入……</p>
</div>
<div class="modeTabs" role="tablist" aria-label="审校模式">
<button id="readingModeBtn" class="modeButton active" type="button">阅读模式</button>
<button id="polishModeBtn" class="modeButton" type="button">精修模式</button>
</div>
<div class="toolbar">
<button id="showTouchedBtn" type="button">仅看已记录</button>
<button id="feedbackBtn" type="button">生成反馈</button>
<button id="applyBtn" type="button">写回 EPUB 内容</button>
<button id="exportBtn" type="button">导出审校 EPUB</button>
</div>
</header>
<main class="layout">
<aside class="sidebar">
<div class="searchbox">
<input id="searchInput" type="search" placeholder="搜索原文、译文、备注">
</div>
<div class="stats" id="stats"></div>
<nav class="toc" id="toc" aria-label="章节目录"></nav>
<div class="rowList" id="rowList"></div>
</aside>
<section class="readerPane" id="readerPane">
<div class="readerHeader">
<div>
<div class="chapterKicker" id="chapterKicker">目录</div>
<h2 id="readerTitle">选择章节开始阅读</h2>
</div>
<div class="readerTools">
<button id="prevPartBtn" type="button">上一章</button>
<button id="nextPartBtn" type="button">下一章</button>
</div>
</div>
<article class="readingFlow" id="readingFlow"></article>
</section>
<section class="editorPane" id="editorPane" hidden>
<div class="emptyState" id="emptyState">选择左侧段落开始审校。</div>
<article class="editorCard" id="editorCard" hidden>
<div class="rowHeader">
<div>
<div class="rowId" id="rowId"></div>
<div class="rowPath" id="rowPath"></div>
</div>
<label class="markToggle">
<input id="markedInput" type="checkbox">
标记为翻译问题
</label>
</div>
<section class="pair">
<h2>日文原文</h2>
<div class="sourceText" id="jpText"></div>
</section>
<section class="pair">
<div class="sectionTitle">
<h2>中文译文</h2>
<div class="inlineTools">
<button id="markSelectionBtn" type="button">标记选中文本</button>
<button id="clearMarksBtn" type="button">清除本段标记</button>
<button id="resetTextBtn" type="button">恢复本段初始译文</button>
</div>
</div>
<div id="cnEditor" class="cnEditor" contenteditable="true" spellcheck="false"></div>
</section>
<section class="reviewFields">
<label>
问题类型
<select id="issueTypeInput">
<option value="">未选择</option>
<option value="误译">误译</option>
<option value="漏译">漏译</option>
<option value="术语不一致">术语不一致</option>
<option value="人名/地名问题">人名/地名问题</option>
<option value="翻译腔">翻译腔</option>
<option value="角色口吻不对">角色口吻不对</option>
<option value="语序不顺">语序不顺</option>
<option value="标点/格式">标点/格式</option>
<option value="其他">其他</option>
</select>
</label>
<label>
严重程度
<select id="severityInput">
<option value="">未选择</option>
<option value="轻微">轻微</option>
<option value="中等">中等</option>
<option value="严重">严重</option>
</select>
</label>
<label>
标签
<input id="tagsInput" type="text" placeholder="例:术语, 对话, 阿格里皮娜">
</label>
</section>
<section class="notes">
<label>
问题备注
<textarea id="commentInput" rows="4" placeholder="写下哪里翻得不好,或你希望我之后怎么处理类似句子。"></textarea>
</label>
<label>
可沉淀为长期规则
<textarea id="learnNoteInput" rows="3" placeholder="例:Falchion 固定译为大砍刀,不要译成偃月刀。"></textarea>
</label>
</section>
<div class="actions">
<button id="prevBtn" type="button">上一段</button>
<button id="saveBtn" type="button">保存本段记录</button>
<button id="nextBtn" type="button">下一段</button>
</div>
</article>
</section>
</main>
<aside class="quickEditor" id="quickEditor" hidden aria-label="快速审校面板">
<div class="quickHeader">
<div>
<div class="rowId" id="quickRowId"></div>
<div class="rowPath" id="quickRowPath"></div>
</div>
<button id="closeQuickEditorBtn" type="button" aria-label="关闭快速审校">关闭</button>
</div>
<details class="quickSourceWrap" open>
<summary>日文原文</summary>
<section class="quickSource" id="quickJpText"></section>
</details>
<label class="quickField">
中文译文
<div id="quickCnEditor" class="cnEditor quickCnEditor" contenteditable="true" spellcheck="false"></div>
</label>
<div class="quickControls">
<label class="markToggle">
<input id="quickMarkedInput" type="checkbox">
标记问题
</label>
<select id="quickIssueTypeInput">
<option value="">问题类型</option>
<option value="误译">误译</option>
<option value="漏译">漏译</option>
<option value="术语不一致">术语不一致</option>
<option value="人名/地名问题">人名/地名问题</option>
<option value="翻译腔">翻译腔</option>
<option value="角色口吻不对">角色口吻不对</option>
<option value="语序不顺">语序不顺</option>
<option value="标点/格式">标点/格式</option>
<option value="其他">其他</option>
</select>
<select id="quickSeverityInput">
<option value="">严重程度</option>
<option value="轻微">轻微</option>
<option value="中等">中等</option>
<option value="严重">严重</option>
</select>
</div>
<label class="quickField">
标签
<input id="quickTagsInput" type="text" placeholder="例:术语, 对话">
</label>
<label class="quickField">
问题备注
<textarea id="quickCommentInput" rows="3" placeholder="哪里翻得不好,或应该如何改。"></textarea>
</label>
<label class="quickField">
可沉淀为长期规则
<textarea id="quickLearnNoteInput" rows="2" placeholder="可复用的翻译偏好或禁忌。"></textarea>
</label>
<div class="quickActions">
<button id="quickMarkSelectionBtn" type="button">标记选中文本</button>
<button id="quickSaveBtn" type="button">保存</button>
<button id="quickOpenFullBtn" type="button">进入精修</button>
</div>
</aside>
<div class="toast" id="toast" hidden></div>
<script src="/static/app.js"></script>
</body>
</html>
+724
View File
@@ -0,0 +1,724 @@
:root {
color-scheme: light;
--bg: #f4f6f8;
--panel: #ffffff;
--panel-soft: #f8fafc;
--ink: #222426;
--muted: #68707a;
--line: #d8dee6;
--line-soft: #e7edf3;
--accent: #1d6f5f;
--accent-strong: #164d43;
--accent-soft: #e3f0ec;
--warn: #a85f00;
--bad: #a43b31;
--source: #73777f;
--mark: #fff0a6;
--shadow: 0 18px 42px rgba(25, 34, 45, 0.18);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-size: 15px;
line-height: 1.65;
}
button,
input,
select,
textarea {
font: inherit;
}
.topbar {
height: 78px;
padding: 10px 18px;
display: grid;
grid-template-columns: minmax(260px, 1fr) auto minmax(320px, 1.15fr);
align-items: center;
gap: 14px;
background: #ffffff;
border-bottom: 1px solid var(--line);
}
.brandBlock {
min-width: 0;
}
h1 {
margin: 0;
font-size: 19px;
font-weight: 700;
}
#sessionMeta {
margin: 2px 0 0;
color: var(--muted);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modeTabs {
display: inline-grid;
grid-template-columns: 1fr 1fr;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
background: #f8fafc;
}
.modeButton {
border: 0;
border-radius: 0;
background: transparent;
min-width: 86px;
}
.modeButton.active {
background: var(--accent);
color: #ffffff;
}
.toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
button {
border: 1px solid var(--line);
background: #ffffff;
color: var(--ink);
min-height: 34px;
padding: 5px 11px;
border-radius: 6px;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
button.primary,
#saveBtn,
#quickSaveBtn,
#exportBtn {
background: var(--accent);
border-color: var(--accent);
color: #ffffff;
}
button.primary:hover,
#saveBtn:hover,
#quickSaveBtn:hover,
#exportBtn:hover {
background: var(--accent-strong);
}
.layout {
height: calc(100vh - 78px);
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
min-width: 0;
}
.sidebar {
border-right: 1px solid var(--line);
background: #f9fbfd;
min-height: 0;
display: flex;
flex-direction: column;
}
.searchbox {
padding: 12px;
border-bottom: 1px solid var(--line);
}
.searchbox input {
width: 100%;
min-height: 36px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px 10px;
background: #ffffff;
}
.stats {
padding: 8px 12px;
color: var(--muted);
font-size: 12px;
border-bottom: 1px solid var(--line);
}
.toc {
max-height: 42%;
overflow: auto;
border-bottom: 1px solid var(--line);
padding: 8px 0;
}
.tocChapter {
margin: 0 8px 6px;
}
.tocChapterButton,
.tocPartButton {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
text-align: left;
border: 0;
border-radius: 6px;
background: transparent;
padding: 7px 8px;
}
.tocChapterButton {
font-weight: 700;
}
.tocPartButton {
padding-left: 18px;
color: var(--muted);
}
.tocChapterButton:hover,
.tocPartButton:hover {
background: #eef3f6;
}
.tocChapterButton.active,
.tocPartButton.active {
background: var(--accent-soft);
color: var(--accent-strong);
}
.tocTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tocCount {
color: var(--muted);
font-weight: 400;
font-size: 12px;
white-space: nowrap;
}
.rowList {
overflow: auto;
min-height: 0;
flex: 1;
}
.rowItem {
width: 100%;
display: block;
text-align: left;
border: 0;
border-bottom: 1px solid #e6e0d6;
border-radius: 0;
background: transparent;
padding: 10px 12px;
}
.rowItem:hover {
background: #eef3f6;
}
.rowItem.active {
background: var(--accent-soft);
border-left: 4px solid var(--accent);
padding-left: 8px;
}
.rowItem.touched .rowBadge {
background: #e9f5ee;
color: var(--accent-strong);
}
.rowItem.marked .rowBadge {
background: #fff0d5;
color: var(--warn);
}
.rowMeta {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 12px;
margin-bottom: 4px;
}
.rowBadge {
display: inline-flex;
align-items: center;
min-height: 20px;
padding: 0 6px;
border-radius: 999px;
background: #e8edf2;
}
.rowPreview {
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.readerPane,
.editorPane {
min-width: 0;
min-height: 0;
overflow: auto;
}
.readerPane {
padding: 0;
background: #f4f6f8;
}
.readerHeader {
position: sticky;
top: 0;
z-index: 5;
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
padding: 16px 28px;
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid var(--line);
}
.chapterKicker {
color: var(--muted);
font-size: 12px;
margin-bottom: 2px;
}
#readerTitle {
margin: 0;
font-size: 20px;
line-height: 1.35;
}
.readerTools {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.readingFlow {
max-width: 920px;
margin: 0 auto;
padding: 24px 38px 90px;
background: #ffffff;
min-height: calc(100vh - 156px);
border-left: 1px solid #e7edf3;
border-right: 1px solid #e7edf3;
}
.readBlock {
position: relative;
padding: 10px 0 14px;
border-bottom: 1px solid #e7edf3;
}
.partDivider {
margin: 26px 0 14px;
padding: 12px 0 8px;
border-bottom: 2px solid var(--line);
color: var(--accent-strong);
font-size: 17px;
}
.readBlock:hover {
background: linear-gradient(90deg, rgba(29, 111, 95, 0.06), transparent 38%);
}
.readBlock.active {
box-shadow: inset 4px 0 0 var(--accent);
padding-left: 12px;
}
.readBlock.marked {
box-shadow: inset 4px 0 0 var(--warn);
padding-left: 12px;
}
.readSource {
color: var(--source);
font-size: 13px;
line-height: 1.75;
margin-bottom: 5px;
}
.readCn {
color: var(--ink);
font-size: 17px;
line-height: 1.9;
letter-spacing: 0;
}
.readCn mark,
.readCn .review-mark,
.cnEditor mark,
.cnEditor .review-mark {
background: var(--mark);
color: inherit;
}
.readMeta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 6px;
color: var(--muted);
font-size: 12px;
opacity: 0;
transition: opacity 0.15s ease;
}
.readBlock:hover .readMeta,
.readBlock.active .readMeta,
.readBlock.touched .readMeta,
.readBlock.marked .readMeta {
opacity: 1;
}
.readStatus {
color: var(--warn);
}
.miniButton {
min-height: 26px;
padding: 2px 8px;
font-size: 12px;
border-radius: 5px;
}
.editorPane {
padding: 18px;
}
.emptyState {
height: 100%;
min-height: 240px;
display: grid;
place-items: center;
color: var(--muted);
}
.editorCard {
max-width: 1040px;
margin: 0 auto;
}
.rowHeader {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: flex-start;
margin-bottom: 14px;
}
.rowId {
font-weight: 700;
font-size: 18px;
}
.rowPath {
color: var(--muted);
font-size: 12px;
word-break: break-all;
}
.markToggle {
white-space: nowrap;
display: flex;
align-items: center;
gap: 8px;
color: var(--warn);
font-weight: 600;
}
.pair,
.reviewFields,
.notes,
.actions {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
}
.pair h2,
.sectionTitle h2 {
margin: 0 0 8px;
font-size: 15px;
}
.sourceText,
.quickSource {
color: var(--source);
white-space: pre-wrap;
background: #f8fafc;
border: 1px solid #e3e9f0;
border-radius: 6px;
padding: 12px;
}
.sectionTitle {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.inlineTools {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.cnEditor {
min-height: 160px;
background: #ffffff;
border: 1px solid var(--line);
border-radius: 6px;
padding: 12px;
outline: none;
white-space: pre-wrap;
}
.cnEditor:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(29, 111, 95, 0.12);
}
.reviewFields {
display: grid;
grid-template-columns: 1fr 1fr 2fr;
gap: 12px;
}
label {
display: flex;
flex-direction: column;
gap: 5px;
font-weight: 600;
}
label input,
label select,
label textarea {
font-weight: 400;
}
input,
select,
textarea {
border: 1px solid var(--line);
border-radius: 6px;
background: #ffffff;
padding: 7px 9px;
color: var(--ink);
}
textarea {
resize: vertical;
}
.notes {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.actions {
display: flex;
justify-content: center;
gap: 10px;
}
.quickEditor {
position: fixed;
top: 92px;
right: 18px;
bottom: 18px;
width: min(520px, calc(100vw - 36px));
z-index: 20;
display: flex;
flex-direction: column;
gap: 10px;
overflow: auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
padding: 14px;
}
.quickHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--line-soft);
padding-bottom: 10px;
}
.quickSourceWrap {
border: 1px solid #e3e9f0;
border-radius: 6px;
background: #f8fafc;
}
.quickSourceWrap summary {
cursor: pointer;
padding: 8px 12px;
color: var(--source);
font-weight: 700;
border-bottom: 1px solid #e3e9f0;
}
.quickSource {
max-height: none;
overflow: visible;
font-size: 13px;
border: 0;
border-radius: 0;
background: transparent;
}
.quickField {
display: flex;
flex-direction: column;
gap: 5px;
}
.quickCnEditor {
min-height: 150px;
max-height: 260px;
overflow: auto;
}
.quickControls {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
align-items: center;
}
.quickActions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 2px;
}
.toast {
position: fixed;
right: 16px;
bottom: 16px;
max-width: min(560px, calc(100vw - 32px));
padding: 12px 14px;
background: #1f2933;
color: #ffffff;
border-radius: 8px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22);
z-index: 30;
white-space: pre-wrap;
}
[hidden] {
display: none !important;
}
@media (min-width: 1180px) {
body.quickOpen .readingFlow {
margin-left: max(40px, calc((100% - 1320px) / 2));
margin-right: 560px;
max-width: 840px;
}
}
@media (max-width: 1040px) {
.topbar {
height: auto;
grid-template-columns: 1fr;
align-items: stretch;
}
#sessionMeta {
white-space: normal;
}
.toolbar {
justify-content: flex-start;
}
.layout {
height: auto;
min-height: calc(100vh - 78px);
grid-template-columns: 1fr;
}
.sidebar {
max-height: 48vh;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.toc {
max-height: 24vh;
}
.readerPane,
.editorPane {
min-height: 70vh;
}
.readingFlow {
border: 0;
padding: 18px 18px 80px;
}
.readerHeader {
padding: 12px 16px;
}
.reviewFields,
.quickControls {
grid-template-columns: 1fr;
}
.quickEditor {
top: auto;
left: 10px;
right: 10px;
bottom: 10px;
width: auto;
max-height: 72vh;
}
}