Initial bilingual EPUB review editor
This commit is contained in:
+22
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.venv/
|
||||
venv/
|
||||
epub_review_sessions/
|
||||
review_sessions/
|
||||
*.log
|
||||
*.epub
|
||||
@@ -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
@@ -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("&", "&")
|
||||
.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();
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user