feat(editor): refine toc hierarchy and search scope

This commit is contained in:
xin61
2026-07-07 15:48:45 +08:00
parent 0aa568636d
commit 9b5c55688a
7 changed files with 530 additions and 137 deletions
+8 -4
View File
@@ -40,8 +40,8 @@ epub_review_sessions/
结构接口 `/api/structure` 应保持向后兼容:
- text chapter: `kind: "text"``parts``row_count``touched_count``marked_count`
- image chapter: `kind: "image"``images``parts: []``row_count: 0`
- 统计字段同时保留总数和细分:`chapter_count``text_chapter_count``image_chapter_count``image_count``asset_count`
- image item: 优先作为 text chapter 的 `items` 子项返回,字段为 `kind: "image"``images``parts: []``row_count: 0`;没有所属文字章时才保留顶层 image chapter
- 统计字段同时保留总数和细分:`chapter_count``text_chapter_count``image_chapter_count``top_level_image_chapter_count``image_count``asset_count`。其中 `image_chapter_count` 表示目录里的图片条目数,不一定是顶层章节数
- part 标题默认使用 `part0000` 形式,真实标题可保留在 `source_title`
- 插图通过 `/api/asset/<entry>` 读取,必须继续使用 `extracted_path()` 防止路径穿越;只把 raster 图片纳入 `/api/structure``/api/asset` 白名单,不能任意读取解包目录文件
@@ -49,16 +49,20 @@ epub_review_sessions/
1. EPUB 内部目录页的中文条目
2. `nav.xhtml` / `toc.xhtml`
3. 正文标题
3. 正文 `<h1-6>` 标题
4. 文件名 fallback
不要把正文首段或第一句对白作为章节标题 fallback。宁可显示 `part0000`,也不要让目录被正文内容污染。
## 阅读与侧栏交互
验收口径:
- 初始进入审校时,左侧侧栏隐藏,正文占主要空间。
- 顶部“目录”只打开目录,“检索”只打开搜索/段落列表,不合并成一个常驻面板。
- 目录可展开/收起章节,插图章节可点击阅读,文字 part 可点击定位。
- 目录可展开/收起章节,插图作为所属章节的次级目录条目可点击阅读,文字 part 可点击定位。
- 点击目录章节、part 或插图条目后,侧栏不得自动收起;用户仍可点“隐藏”或按 `Escape` 关闭。
- 检索必须保留“当前范围 / 全书全局”切换;当前范围随当前章节或 part 更新,全局检索可跨章节返回并跳转。
- 阅读模式不显示每段编号、快速编辑、快速标记按钮。
- 点击正文段落打开右侧精修与重翻面板。
- 右侧面板打开或关闭导致正文宽度变化时,应恢复用户原来正在看的段落位置。
+6 -3
View File
@@ -1,6 +1,6 @@
# 双语 EPUB 审校编辑器
当前版本:`0.4.1`
当前版本:`0.5.0`
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
@@ -24,6 +24,8 @@
`0.4.1` 修复后端/API 安全与结构细节:章节内插图与文字按 spine 顺序 flush,结构统计补充 text/image 细分字段;内部目录识别增加目录页/链接密度判定;结构和 `/api/asset` 仅允许已识别的 raster 图片并加 `nosniff`;重翻接口不再允许单次请求临时覆盖 Base URL 或模型后复用已保存密钥。
`0.5.0` 改进长篇目录与检索:目录章节标题不再用正文首段猜测,章节内插图作为次级条目挂在所属章节下,阅读时可按原 spine 顺序穿插显示;点击目录不会自动收起侧栏,桌面端正文会为常驻侧栏让位;检索新增“当前范围 / 全书全局”切换。
## 独立安装
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
@@ -81,8 +83,9 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio
- 阅读模式下按章节连续阅读日文原文与中文译文,不需要逐句点击。
- 左侧目录和检索默认隐藏,通过顶部“目录”“检索”两个按钮分别打开,不会把两种入口混在同一个常驻侧栏里。
- 目录优先按 EPUB 内部目录页或 nav/toc 组织章节;章节下显示内部 `part0000` 形式的 part,可展开/收起,点击 part 后只看该 part,点击章节则看整章连续正文
- 插图页会像文字章节一样出现在目录里,点击后可在阅读模式中直接查看原 EPUB 图片。
- 目录优先按 EPUB 内部目录页或 nav/toc 组织章节;章节下显示内部 `part0000` 形式的 part 与次级插图条目,可展开/收起,点击 part 后只看该 part,点击章节则按原 spine 顺序连续阅读正文和插图
- 插图页会作为所属章节的次级条目出现在目录里,点击后可在阅读模式中直接查看原 EPUB 图片;点击目录项不会自动隐藏左侧侧栏
- 检索支持“当前范围”和“全书全局”两种范围,适合在单章精查和整本书排查术语时切换。
- 在阅读模式中点击段落即可打开右侧精修与重翻面板,直接编辑中文译文、标记问题或调用 GPT 重翻;打开/关闭面板时会尽量保持原来的阅读位置。
- 精修模式保留原有逐段编辑界面,用于较细的逐段复审。
- 可在网页中配置 OpenAI 兼容的 `base_url``model` 与 API Key,对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。
+131 -34
View File
@@ -799,9 +799,18 @@ def collect_asset_paths_from_structure(structure: dict[str, Any]) -> set[str]:
asset_path = image.get("asset_path", "")
if asset_path:
paths.add(asset_path)
for item in chapter.get("items", []):
for image in item.get("images", []):
asset_path = image.get("asset_path", "")
if asset_path:
paths.add(asset_path)
return paths
def image_item_count(chapters: list[dict[str, Any]]) -> int:
return sum(1 for chapter in chapters for item in chapter.get("items", []) if item.get("kind") == "image")
def nav_target_key(base_dir: str, href: str) -> tuple[str, str]:
path, fragment = normalize_href_with_fragment(base_dir, href)
return path, fragment
@@ -826,6 +835,7 @@ def parse_internal_toc(session_root: Path) -> list[dict[str, Any]]:
candidate_items: list[dict[str, Any]] = []
i = 0
while i < len(matches):
source_attrs = matches[i].group(1)[2:-1]
source_inner = matches[i].group(2)
link_match = re.search(r"<a\b[^>]*href=['\"]([^'\"]+)['\"][^>]*>(.*?)</a>", source_inner, flags=re.S | re.I)
if not link_match:
@@ -840,13 +850,20 @@ def parse_internal_toc(session_root: Path) -> list[dict[str, Any]]:
continue
source_title = compact_text(strip_tags(link_match.group(2))) or compact_text(strip_tags(source_inner))
title = source_title
if i + 1 < len(matches):
if is_gray_source(source_attrs, source_inner) and i + 1 < len(matches):
next_attrs = matches[i + 1].group(1)[2:-1]
next_inner = matches[i + 1].group(2)
if not is_gray_source(matches[i + 1].group(1)[2:-1], next_inner):
next_title = compact_text(strip_tags(next_inner))
if not is_gray_source(next_attrs, next_inner):
next_link = re.search(r"<a\b[^>]*href=['\"]([^'\"]+)['\"][^>]*>(.*?)</a>", next_inner, flags=re.S | re.I)
same_target = True
if next_link:
next_target_file, next_target_fragment = nav_target_key(base_dir, next_link.group(1))
same_target = next_target_file == target_file and next_target_fragment == target_fragment
next_title = compact_text(strip_tags(next_link.group(2) if next_link else next_inner))
if next_title:
title = next_title
i += 1
if same_target:
title = next_title
i += 1
if title:
candidate_items.append(
{
@@ -1000,13 +1017,31 @@ def build_structure(session_root: Path) -> dict[str, Any]:
if start and item.get("title") and start not in start_titles:
start_titles[start] = item["title"]
for entry_name in ordered_files:
heading = read_body_heading(session_root, entry_name)
if heading and entry_name not in start_titles and len(strip_tags(heading)) <= 80:
start_titles[entry_name] = heading
if not start_titles:
for item in nav_flat:
href = item.get("href", "")
if href not in ordered_index:
continue
start = first_content_file_at_or_after(href)
if start and item.get("title") and start not in start_titles:
start_titles[start] = item["title"]
if not start_titles:
for entry_name in ordered_files:
heading = read_body_heading(session_root, entry_name)
if heading and entry_name not in start_titles and len(strip_tags(heading)) <= 80:
start_titles[entry_name] = heading
if start_titles:
starts = sorted(start_titles, key=lambda name: ordered_index.get(name, 10**9))
first_start_pos = ordered_index.get(starts[0], len(all_ordered_files)) if starts else len(all_ordered_files)
preface_files = [
entry_name
for entry_name in all_ordered_files[:first_start_pos]
if entry_name in rows_by_file or entry_name in image_by_file
]
if preface_files:
chapter_specs.append({"title": "卷首", "files": preface_files, "start": preface_files[0]})
for index, start in enumerate(starts):
start_pos = ordered_index.get(start, -1)
next_pos = ordered_index.get(starts[index + 1], len(all_ordered_files)) if index + 1 < len(starts) else len(all_ordered_files)
@@ -1057,8 +1092,6 @@ def build_structure(session_root: Path) -> dict[str, Any]:
"source_title": title_override
or nav_titles.get(entry_name)
or read_body_heading(session_root, entry_name)
or first_content_text(session_root, entry_name)
or read_document_title(session_root, entry_name)
or Path(entry_name).stem,
"file": entry_name,
"file_label": Path(entry_name).name,
@@ -1070,21 +1103,23 @@ def build_structure(session_root: Path) -> dict[str, Any]:
"last_row_id": file_rows[-1]["id"] if file_rows else "",
}
def make_image_chapter(
def make_image_item(
entry_name: str,
image: dict[str, Any],
title_override: str = "",
image_index: int = 1,
image_total: int = 1,
) -> dict[str, Any]:
nonlocal chapter_serial, image_serial
chapter_serial += 1
nonlocal image_serial
image_serial += 1
suffix = f"-{image_index:02d}" if image_total > 1 else ""
label = part_display_label(entry_name, image_serial - 1)
default_title = "封面" if Path(entry_name).stem.lower() in {"titlepage", "cover"} else f"插图 {label}{suffix}"
is_cover = Path(entry_name).stem.lower() in {"titlepage", "cover"}
default_title = "封面" if is_cover else f"插图 {label}{suffix}"
base_title = title_override or nav_titles.get(entry_name) or read_body_heading(session_root, entry_name)
if title_override and base_title:
if is_cover:
title = default_title
elif title_override and base_title:
title = f"{base_title} · 插图{suffix}"
else:
title = base_title or default_title
@@ -1093,7 +1128,7 @@ def build_structure(session_root: Path) -> dict[str, Any]:
"id": f"IMG{image_serial:04d}-{image['index']:02d}",
}
return {
"id": f"C{chapter_serial:04d}",
"id": image_item["id"],
"kind": "image",
"title": title,
"file": entry_name,
@@ -1103,10 +1138,21 @@ def build_structure(session_root: Path) -> dict[str, Any]:
"touched_count": 0,
"marked_count": 0,
"parts": [],
"_sort": ordered_index.get(entry_name, 10**9) + image_index / 100,
"first_image_id": image_item["id"],
"last_image_id": image_item["id"],
}
def chapter_from_parts(chapter_id: str, title: str, parts: list[dict[str, Any]]) -> dict[str, Any]:
def image_chapter_from_item(chapter_id: str, item: dict[str, Any]) -> dict[str, Any]:
return {
**item,
"id": chapter_id,
"items": [item],
"_sort": ordered_index.get(item.get("file", ""), 10**9),
}
def chapter_from_items(chapter_id: str, title: str, items: list[dict[str, Any]]) -> dict[str, Any]:
parts = [item for item in items if item.get("kind") == "text"]
images = [image for item in items if item.get("kind") == "image" for image in item.get("images", [])]
return {
"id": chapter_id,
"kind": "text",
@@ -1115,9 +1161,34 @@ def build_structure(session_root: Path) -> dict[str, Any]:
"touched_count": sum(part["touched_count"] for part in parts),
"marked_count": sum(part["marked_count"] for part in parts),
"parts": parts,
"_sort": min((ordered_index.get(part["file"], 10**9) for part in parts), default=10**9),
"images": images,
"items": items,
"_sort": min((ordered_index.get(item.get("file", ""), 10**9) for item in items), default=10**9),
}
def chapter_from_parts(chapter_id: str, title: str, parts: list[dict[str, Any]]) -> dict[str, Any]:
return chapter_from_items(chapter_id, title, parts)
def fold_image_chapters_into_text(input_chapters: list[dict[str, Any]]) -> list[dict[str, Any]]:
folded: list[dict[str, Any]] = []
last_text_chapter: dict[str, Any] | None = None
for chapter in input_chapters:
if chapter.get("kind") != "image":
folded.append(chapter)
last_text_chapter = chapter if chapter.get("kind") == "text" else last_text_chapter
continue
if last_text_chapter is None:
folded.append(chapter)
continue
image_items = chapter.get("items") or [chapter]
last_text_chapter.setdefault("items", []).extend(image_items)
last_text_chapter.setdefault("images", []).extend(
image
for item in image_items
for image in item.get("images", [])
)
return folded
def flatten_node_files(node: dict[str, Any]) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
href = node.get("href", "")
@@ -1136,7 +1207,9 @@ def build_structure(session_root: Path) -> dict[str, Any]:
if entry_name in image_by_file:
images = image_by_file.get(entry_name, [])
for image_index, image in enumerate(images, start=1):
chapters.append(make_image_chapter(entry_name, image, title_override, image_index, len(images)))
chapter_serial += 1
image_item = make_image_item(entry_name, image, title_override, image_index, len(images))
chapters.append(image_chapter_from_item(f"C{chapter_serial:04d}", image_item))
return
if entry_name in rows_by_file:
chapter_serial += 1
@@ -1145,28 +1218,36 @@ def build_structure(session_root: Path) -> dict[str, Any]:
def append_grouped_files(title: str, files: list[str]) -> None:
nonlocal chapter_serial
text_parts: list[dict[str, Any]] = []
chapter_items: list[dict[str, Any]] = []
text_title = title
def flush_text_parts() -> None:
nonlocal chapter_serial, text_parts, text_title
def flush_chapter_items() -> None:
nonlocal chapter_serial, chapter_items, text_title
if not chapter_items:
return
text_parts = [item for item in chapter_items if item.get("kind") == "text"]
if not text_parts:
for item in chapter_items:
if item.get("kind") == "image":
chapter_serial += 1
chapters.append(image_chapter_from_item(f"C{chapter_serial:04d}", item))
chapter_items = []
text_title = ""
return
chapter_serial += 1
chapters.append(chapter_from_parts(f"C{chapter_serial:04d}", text_title or text_parts[0]["source_title"] or text_parts[0]["title"], text_parts))
text_parts = []
chapters.append(chapter_from_items(f"C{chapter_serial:04d}", text_title or text_parts[0]["source_title"] or text_parts[0]["title"], chapter_items))
chapter_items = []
text_title = ""
for file_index, entry_name in enumerate(files):
if entry_name in image_by_file:
flush_text_parts()
images = image_by_file.get(entry_name, [])
for image_index, image in enumerate(images, start=1):
image_title = title if file_index == 0 and not text_parts else ""
chapters.append(make_image_chapter(entry_name, image, image_title, image_index, len(images)))
image_title = title if file_index == 0 and not chapter_items else ""
chapter_items.append(make_image_item(entry_name, image, image_title, image_index, len(images)))
elif entry_name in rows_by_file:
text_parts.append(make_part(entry_name))
flush_text_parts()
chapter_items.append(make_part(entry_name))
flush_chapter_items()
for spec in chapter_specs:
node_files = [entry_name for entry_name in spec.get("files", []) if entry_name not in assigned_files]
@@ -1207,17 +1288,33 @@ def build_structure(session_root: Path) -> dict[str, Any]:
append_file_as_chapter(entry_name)
chapters.sort(key=lambda chapter: chapter.get("_sort", 10**9))
chapters = fold_image_chapters_into_text(chapters)
for index, chapter in enumerate(chapters, start=1):
chapter["id"] = f"C{index:04d}"
chapter.pop("_sort", None)
if chapter.get("kind") == "text":
for item_index, item in enumerate(chapter.get("items", []), start=1):
item["parent_chapter_id"] = chapter["id"]
item["display_level"] = 1
item["toc_title"] = item.get("title") or item.get("file_label") or item.get("id") or f"item{item_index:04d}"
else:
chapter.setdefault("display_level", 0)
return {
"chapters": chapters,
"chapter_count": len(chapters),
"text_chapter_count": sum(1 for chapter in chapters if chapter.get("kind") != "image"),
"image_chapter_count": sum(1 for chapter in chapters if chapter.get("kind") == "image"),
"part_count": sum(len(chapter["parts"]) for chapter in chapters),
"image_count": sum(len(chapter.get("images", [])) for chapter in chapters),
"image_chapter_count": image_item_count(chapters) or sum(1 for chapter in chapters if chapter.get("kind") == "image"),
"top_level_image_chapter_count": sum(1 for chapter in chapters if chapter.get("kind") == "image"),
"part_count": sum(len(chapter.get("parts", [])) for chapter in chapters),
"image_count": sum(
len(chapter.get("images", [])) if chapter.get("kind") == "image" else sum(
len(item.get("images", []))
for item in chapter.get("items", [])
if item.get("kind") == "image"
)
for chapter in chapters
),
"asset_count": len(collect_asset_paths_from_structure({"chapters": chapters})),
"row_count": len(rows),
}
+304 -90
View File
@@ -5,6 +5,7 @@ let currentIndex = -1;
let currentRow = null;
let currentChapter = null;
let currentPart = null;
let currentImageItem = null;
let currentMode = "reading";
let showTouchedOnly = false;
let dirty = false;
@@ -12,6 +13,7 @@ let quickDirty = false;
let hasSession = false;
let expandedChapters = new Set();
let sidePanelMode = null;
let searchScope = "current";
let gptConfigLoaded = false;
let gptCandidateHtml = "";
@@ -37,6 +39,8 @@ const dom = {
tocPanel: $("tocPanel"),
searchPanel: $("searchPanel"),
searchInput: $("searchInput"),
searchScopeCurrentBtn: $("searchScopeCurrentBtn"),
searchScopeBookBtn: $("searchScopeBookBtn"),
stats: $("stats"),
expandTocBtn: $("expandTocBtn"),
collapseTocBtn: $("collapseTocBtn"),
@@ -238,14 +242,18 @@ function resetReviewState() {
currentRow = null;
currentChapter = null;
currentPart = null;
currentImageItem = null;
showTouchedOnly = false;
dirty = false;
quickDirty = false;
expandedChapters = new Set();
sidePanelMode = null;
searchScope = "current";
gptCandidateHtml = "";
gptConfigLoaded = false;
dom.showTouchedBtn.classList.remove("primary");
dom.searchScopeCurrentBtn.classList.add("active");
dom.searchScopeBookBtn.classList.remove("active");
dom.searchInput.value = "";
dom.sidebar.hidden = true;
dom.tocPanel.hidden = true;
@@ -352,10 +360,33 @@ function rowById(rowId) {
return rows.find((item) => item.id === rowId) || null;
}
function itemKind(item) {
return item && item.kind === "image" ? "image" : "text";
}
function isImageItem(item) {
return itemKind(item) === "image";
}
function allParts() {
return (structure.chapters || []).flatMap((chapter) => chapter.parts || []);
}
function chapterItems(chapter) {
if (!chapter) return [];
if (Array.isArray(chapter.items) && chapter.items.length) return chapter.items;
if (isImageChapter(chapter)) return [{ ...chapter, id: `${chapter.id}:image`, parent_chapter_id: chapter.id, display_level: 1 }];
return chapter.parts || [];
}
function chapterImages(chapter) {
if (!chapter) return [];
if (isImageChapter(chapter)) return chapter.images || [];
return chapterItems(chapter)
.filter(isImageItem)
.flatMap((item) => item.images || []);
}
function allChapterScopes() {
return (structure.chapters || []).map((chapter) => ({
type: "chapter",
@@ -364,11 +395,20 @@ function allChapterScopes() {
}));
}
function activeChapterScopeId() {
if (currentImageItem && currentChapter) return currentChapter.id;
return currentChapter ? currentChapter.id : chapterForPart(currentPart)?.id;
}
function chapterIsExpanded(chapter) {
if (!chapter) return false;
return expandedChapters.has(chapter.id);
}
function chapterHasChildren(chapter) {
return chapterItems(chapter).length > 0;
}
function toggleChapterExpanded(chapter) {
if (!chapter) return;
if (expandedChapters.has(chapter.id)) {
@@ -386,7 +426,8 @@ function expandAllChapters() {
function collapseAllChapters() {
expandedChapters = new Set();
if (currentChapter) expandedChapters.add(currentChapter.id);
const activeId = activeChapterScopeId();
if (activeId) expandedChapters.add(activeId);
renderToc();
}
@@ -424,10 +465,34 @@ function chapterForPart(part) {
function chapterRows(chapter) {
if (!chapter) return [];
if (isImageChapter(chapter)) return [];
const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || []));
return rows.filter((row) => ids.has(row.id));
}
function searchBaseRows() {
return searchScope === "book" ? rows : scopeRows();
}
function visibleRowsForScope() {
return filteredRows;
}
function updateSearchScopeButtons() {
dom.searchScopeCurrentBtn.classList.toggle("active", searchScope === "current");
dom.searchScopeBookBtn.classList.toggle("active", searchScope === "book");
}
function setSearchScope(scope) {
searchScope = scope === "book" ? "book" : "current";
updateSearchScopeButtons();
if (searchScope === "current" && (currentImageItem || (isImageChapter(currentChapter) && !currentPart))) {
currentRow = null;
currentIndex = -1;
}
applyFilter(true);
}
function scopeCoversRow(row) {
if (!row) return false;
if (currentPart) return (currentPart.row_ids || []).includes(row.id);
@@ -442,6 +507,7 @@ function syncScopeToRow(row, forcePart = false) {
if (!nextPart) return;
const nextChapter = chapterForPart(nextPart);
if (!nextChapter) return;
currentImageItem = null;
if (forcePart || !currentChapter || currentChapter.id !== nextChapter.id || !scopeCoversRow(row)) {
currentChapter = nextChapter;
currentPart = nextPart;
@@ -458,12 +524,20 @@ function partVisibleRows(part) {
return filteredRows.filter((row) => ids.has(row.id));
}
function chapterVisibleRows(chapter) {
if (!chapter) return [];
function chapterVisibleRowsInFilter(chapter) {
if (!chapter) return filteredRows;
const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || []));
return filteredRows.filter((row) => ids.has(row.id));
}
function chapterVisibleRows(chapter) {
if (!chapter) return filteredRows;
if (searchScope === "book") {
return chapterVisibleRowsInFilter(chapter);
}
return filteredRows;
}
function scopeRows() {
if (currentPart) return partRows(currentPart);
if (currentChapter) return chapterRows(currentChapter);
@@ -471,13 +545,16 @@ function scopeRows() {
}
function scopeVisibleRows() {
if (currentPart) return partVisibleRows(currentPart);
if (currentChapter) return chapterVisibleRows(currentChapter);
if (searchScope === "book") {
if (currentPart) return partVisibleRows(currentPart);
if (currentChapter) return chapterVisibleRows(currentChapter);
return filteredRows;
}
return filteredRows;
}
function imageChapterMatchesSearch(chapter) {
if (!isImageChapter(chapter)) return false;
if (!isImageChapter(chapter) && !isImageItem(chapter)) return false;
if (showTouchedOnly) return false;
const q = dom.searchInput.value.trim().toLowerCase();
if (!q) return true;
@@ -533,7 +610,13 @@ async function loadRows(keepSelection = false) {
if (!currentPart) {
currentChapter = (structure.chapters || [])[0] || null;
currentPart = currentChapter && !isImageChapter(currentChapter) && (currentChapter.parts || []).length === 1 ? currentChapter.parts[0] : null;
currentPart = currentChapter
&& !isImageChapter(currentChapter)
&& (currentChapter.parts || []).length === 1
&& chapterImages(currentChapter).length === 0
? currentChapter.parts[0]
: null;
applyFilter(false);
}
if (filteredRows.length > 0 && currentIndex < 0) {
currentRow = filteredRows[0];
@@ -596,7 +679,13 @@ function updateStructureCountsFromRows() {
chapter.row_count = 0;
chapter.touched_count = 0;
chapter.marked_count = 0;
if (isImageChapter(chapter)) continue;
if (isImageChapter(chapter)) {
chapter.row_count = 0;
chapter.touched_count = 0;
chapter.marked_count = 0;
chapter.items = chapterItems(chapter);
continue;
}
for (const part of chapter.parts || []) {
const partItems = partRows(part);
part.row_count = partItems.length;
@@ -606,6 +695,12 @@ function updateStructureCountsFromRows() {
chapter.touched_count += part.touched_count;
chapter.marked_count += part.marked_count;
}
for (const item of chapter.items || []) {
if (!isImageItem(item)) {
const match = (chapter.parts || []).find((part) => part.id === item.id);
if (match) Object.assign(item, match);
}
}
}
}
@@ -631,7 +726,7 @@ function rowMatchesSearch(row, q) {
function applyFilter(shouldRender = true) {
const q = dom.searchInput.value.trim().toLowerCase();
filteredRows = rows.filter((row) => rowMatchesSearch(row, q));
filteredRows = searchBaseRows().filter((row) => rowMatchesSearch(row, q));
if (currentRow) {
currentIndex = filteredRows.findIndex((row) => row.id === currentRow.id);
}
@@ -656,7 +751,7 @@ function renderMode() {
dom.editorPane.hidden = currentMode !== "polish";
dom.readingModeBtn.classList.toggle("active", currentMode === "reading");
dom.polishModeBtn.classList.toggle("active", currentMode === "polish");
dom.polishModeBtn.disabled = !hasSession || (isImageChapter(currentChapter) && !currentPart);
dom.polishModeBtn.disabled = !hasSession || ((currentImageItem || isImageChapter(currentChapter)) && !currentPart);
document.body.classList.toggle("quickOpen", currentMode === "reading" && !dom.quickEditor.hidden);
if (sidePanelMode) {
dom.tocPanel.hidden = sidePanelMode !== "toc";
@@ -668,11 +763,13 @@ function renderToc() {
dom.toc.innerHTML = "";
const frag = document.createDocumentFragment();
for (const chapter of structure.chapters || []) {
const items = chapterItems(chapter);
const parts = chapter.parts || [];
const imageItems = items.filter(isImageItem);
const chapterIsImage = isImageChapter(chapter);
const isCurrentChapter = currentChapter && chapter.id === currentChapter.id;
const containsCurrentPart = parts.some((part) => currentPart && part.id === currentPart.id);
const expanded = !chapterIsImage && (chapterIsExpanded(chapter) || containsCurrentPart);
const expanded = chapterIsExpanded(chapter) || containsCurrentPart || (isCurrentChapter && (!currentPart || currentImageItem));
const group = document.createElement("section");
group.className = "tocChapter";
if (chapterIsImage) group.classList.add("imageChapter");
@@ -687,13 +784,12 @@ function renderToc() {
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "tocToggle";
toggle.textContent = chapterIsImage ? "图" : expanded ? "▾" : "▸";
toggle.disabled = chapterIsImage || parts.length === 0;
toggle.setAttribute("aria-label", chapterIsImage ? "插图章节" : expanded ? "收起章节" : "展开章节");
toggle.textContent = expanded ? "▾" : "▸";
toggle.disabled = !chapterHasChildren(chapter);
toggle.setAttribute("aria-label", expanded ? "收起章节" : "展开章节");
toggle.setAttribute("aria-expanded", expanded ? "true" : "false");
toggle.addEventListener("click", (event) => {
event.stopPropagation();
if (chapterIsImage) return;
toggleChapterExpanded(chapter);
});
row.appendChild(toggle);
@@ -709,7 +805,7 @@ function renderToc() {
}
title.innerHTML = `
<span class="tocTitle">${escapeHtml(chapter.title || chapter.id)}</span>
<span class="tocCount">${chapterIsImage ? `${(chapter.images || []).length || 1}` : `${chapter.row_count || 0}`}</span>
<span class="tocCount">${chapterIsImage ? `${chapterImages(chapter).length || 1}` : `${chapter.row_count || 0}${imageItems.length ? ` · ${imageItems.length}` : ""}`}</span>
`;
title.addEventListener("click", () => selectChapter(chapter));
row.appendChild(title);
@@ -718,54 +814,84 @@ function renderToc() {
const partList = document.createElement("div");
partList.className = "tocParts";
partList.hidden = !expanded;
if (!chapterIsImage) for (const part of 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);
for (const item of items) {
if (isImageItem(item)) {
const button = document.createElement("button");
button.type = "button";
button.className = "tocImageButton";
const imageActive = isCurrentChapter && !currentPart && (
currentImageItem ? currentImageItem.id === item.id : chapterIsImage
);
if (imageActive) button.classList.add("active");
button.innerHTML = `
<span class="tocTitle">${escapeHtml(item.toc_title || item.title || item.file_label || "插图")}</span>
<span class="tocCount">${(item.images || []).length || 1} 图</span>
`;
button.addEventListener("click", () => selectImageItem(chapter, item));
partList.appendChild(button);
} else {
const part = item;
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);
requestAnimationFrame(() => {
dom.toc.querySelector(".tocPartButton.active, .tocImageButton.active, .tocChapterButton.active")?.scrollIntoView({ block: "nearest" });
});
}
function renderList() {
const touched = rows.filter(rowTouched).length;
const marked = rows.filter((row) => row.marked).length;
const visibleRows = visibleRowsForScope();
const visibleInScope = scopeVisibleRows();
const imageCount = structure.image_count || 0;
const isCurrentImage = isImageChapter(currentChapter) && !currentPart;
dom.stats.textContent = `显示 ${filteredRows.length} / ${rows.length} 段 · 当前 ${isCurrentImage ? `${(currentChapter.images || []).length}` : `${visibleInScope.length}`} · 插图 ${imageCount} · 已记录 ${touched} · 标记 ${marked}`;
const isBrowsingImage = !dom.searchInput.value.trim() && !showTouchedOnly;
const isCurrentImage = searchScope !== "book" && isBrowsingImage && !currentPart && (Boolean(currentImageItem) || isImageChapter(currentChapter));
const scopeLabel = searchScope === "book" ? "全书" : "当前范围";
dom.stats.textContent = `${scopeLabel}显示 ${visibleRows.length} / ${rows.length} 段 · 当前 ${isCurrentImage ? `${(currentImageItem ? currentImageItem.images || [] : chapterImages(currentChapter)).length}` : `${visibleInScope.length}`} · 插图 ${imageCount} · 已记录 ${touched} · 标记 ${marked}`;
dom.rowList.innerHTML = "";
const frag = document.createDocumentFragment();
if (isCurrentImage) {
const item = document.createElement("div");
item.className = "rowItem imageRowItem active";
const images = currentChapter.images || [];
item.innerHTML = `
<div class="rowMeta">
<span>${escapeHtml(currentChapter.id)}</span>
<span class="rowBadge">插图</span>
<span>${escapeHtml(currentChapter.file_label || "")}</span>
</div>
<div class="rowPreview">${escapeHtml(images.map((image) => image.asset_name || image.asset_path).join(" / ") || "插图章节")}</div>
<div class="rowPreview">${escapeHtml((currentImageItem ? currentImageItem.images || [] : chapterImages(currentChapter)).map((image) => image.asset_name || image.asset_path).join(" / ") || "插图章节")}</div>
`;
frag.appendChild(item);
dom.rowList.appendChild(frag);
return;
}
visibleInScope.forEach((row) => {
if (!visibleRows.length) {
const item = document.createElement("div");
item.className = "rowItem emptyRowItem";
item.textContent = dom.searchInput.value || showTouchedOnly ? "当前检索范围没有命中段落。" : "当前范围没有可检索段落。";
frag.appendChild(item);
dom.rowList.appendChild(frag);
return;
}
visibleRows.forEach((row) => {
const button = document.createElement("button");
button.type = "button";
button.className = "rowItem";
@@ -789,7 +915,12 @@ function renderList() {
}
function renderImageChapter(chapter) {
const images = chapter.images || [];
const items = chapterItems(chapter).filter(isImageItem);
if (items.length > 1) {
renderMixedChapterImages(chapter, items);
return;
}
const images = chapterImages(chapter);
dom.chapterKicker.textContent = chapter.file_label || "插图";
dom.readerTitle.textContent = chapter.title || "插图";
dom.readingFlow.innerHTML = "";
@@ -813,6 +944,73 @@ function renderImageChapter(chapter) {
dom.readingFlow.appendChild(frag);
}
function createImageFigure(item) {
const frag = document.createDocumentFragment();
(item.images || []).forEach((image, index) => {
const figure = document.createElement("figure");
figure.className = "imageChapterFigure inlineImageFigure";
figure.innerHTML = `
<img src="${escapeHtml(image.asset_url || "")}" alt="${escapeHtml(image.alt || item.title || "")}" loading="lazy">
<figcaption>
<span>${escapeHtml(item.title || `插图 ${index + 1}`)}</span>
<span>${escapeHtml(image.asset_name || image.asset_path || "")}</span>
</figcaption>
`;
frag.appendChild(figure);
});
return frag;
}
function renderMixedChapterImages(chapter, imageItems, forceShow = false) {
dom.chapterKicker.textContent = chapter ? chapter.title : "插图";
dom.readerTitle.textContent = imageItems.length === 1
? imageItems[0].title || "插图"
: chapter ? chapter.title : "插图";
dom.readingFlow.innerHTML = "";
const frag = document.createDocumentFragment();
imageItems.forEach((item) => {
if (forceShow || imageChapterMatchesSearch(item)) frag.appendChild(createImageFigure(item));
});
if (!frag.childNodes.length) {
dom.readingFlow.innerHTML = '<div class="emptyState">当前筛选条件下没有插图。</div>';
return;
}
dom.readingFlow.appendChild(frag);
}
function rowPassesCurrentFilters(row) {
const q = dom.searchInput.value.trim().toLowerCase();
return rowMatchesSearch(row, q);
}
function rowsForReadingPart(part) {
const visible = searchScope === "book"
? partRows(part).filter(rowPassesCurrentFilters)
: partVisibleRows(part);
if (visible.length || dom.searchInput.value || showTouchedOnly) return visible;
return partRows(part);
}
function appendReadBlock(frag, row) {
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");
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>
`;
block.addEventListener("click", () => {
maybeSelectRow(row, { openQuick: true, scroll: false });
});
frag.appendChild(block);
}
function renderReading() {
const selectedChapter = currentChapter || chapterForPart(currentPart);
if (!currentPart && !selectedChapter) {
@@ -823,6 +1021,10 @@ function renderReading() {
}
const selectedPart = currentPart;
if (!selectedPart && currentImageItem) {
renderMixedChapterImages(selectedChapter, [currentImageItem], true);
return;
}
if (!selectedPart && isImageChapter(selectedChapter)) {
renderImageChapter(selectedChapter);
return;
@@ -833,49 +1035,34 @@ function renderReading() {
: 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>';
const frag = document.createDocumentFragment();
if (selectedPart) {
rowsForReadingPart(selectedPart).forEach((row) => appendReadBlock(frag, row));
} else {
for (const item of chapterItems(selectedChapter)) {
if (isImageItem(item)) {
if (imageChapterMatchesSearch(item)) frag.appendChild(createImageFigure(item));
continue;
}
const partRowsToRender = rowsForReadingPart(item);
if (!partRowsToRender.length) continue;
const divider = document.createElement("h3");
divider.className = "partDivider";
divider.textContent = item.title || item.file_label || item.file || "part";
frag.appendChild(divider);
partRowsToRender.forEach((row) => appendReadBlock(frag, row));
}
}
if (!frag.childNodes.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");
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>
`;
block.addEventListener("click", () => {
maybeSelectRow(row, { openQuick: true, scroll: false });
});
frag.appendChild(block);
});
dom.readingFlow.appendChild(frag);
}
function renderEditor() {
if (isImageChapter(currentChapter) && !currentPart) {
if ((currentImageItem || isImageChapter(currentChapter)) && !currentPart) {
dom.emptyState.hidden = false;
dom.emptyState.textContent = "插图章节没有可编辑译文,请切回阅读模式查看图片。";
dom.editorCard.hidden = true;
@@ -997,7 +1184,7 @@ async function saveRow(row, data, silent = false) {
async function saveCurrent(silent = false) {
if (!hasSession) return true;
if (isImageChapter(currentChapter) && !currentPart) return true;
if ((currentImageItem || isImageChapter(currentChapter)) && !currentPart) return true;
if (!currentRow) return true;
return saveRow(currentRow, collectCurrent(), silent);
}
@@ -1021,8 +1208,11 @@ async function maybeSelectRow(row, options = {}) {
function selectRow(row, options = {}) {
if (!row) return;
currentRow = row;
currentIndex = filteredRows.findIndex((item) => item.id === row.id);
if (isImageChapter(currentChapter) && !currentPart) currentChapter = null;
currentImageItem = null;
syncScopeToRow(row, Boolean(options.forcePart));
applyFilter(false);
currentIndex = filteredRows.findIndex((item) => item.id === row.id);
dirty = false;
if (options.openEditor) {
@@ -1042,24 +1232,24 @@ async function selectChapter(chapter) {
if (!ok) return;
currentChapter = chapter;
currentPart = null;
currentImageItem = null;
expandedChapters.add(chapter.id);
applyFilter(false);
if (isImageChapter(chapter)) {
currentRow = null;
currentIndex = -1;
closeQuickEditor(false);
currentMode = "reading";
} else {
const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || []));
const visible = filteredRows.filter((row) => ids.has(row.id));
const fallback = rows.filter((row) => ids.has(row.id));
currentRow = visible[0] || fallback[0] || currentRow;
const visible = searchScope === "book" ? chapterVisibleRowsInFilter(chapter) : filteredRows;
const fallback = scopeRows();
currentRow = visible[0] || fallback[0] || null;
currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1;
}
renderAll();
if (currentMode === "reading") {
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
}
if (sidePanelMode === "toc") closeSidePanel();
}
async function selectPart(part) {
@@ -1068,16 +1258,34 @@ async function selectPart(part) {
if (!ok) return;
currentChapter = chapterForPart(part);
currentPart = part;
currentImageItem = null;
if (currentChapter) expandedChapters.add(currentChapter.id);
applyFilter(false);
const visible = partVisibleRows(part);
const fallback = partRows(part);
currentRow = visible[0] || fallback[0] || currentRow;
currentRow = visible[0] || fallback[0] || null;
currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1;
renderAll();
if (currentMode === "reading") {
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
}
if (sidePanelMode === "toc") closeSidePanel();
}
async function selectImageItem(chapter, item) {
if (!chapter || !item) return;
const ok = await maybeSaveBeforeSwitch();
if (!ok) return;
currentChapter = chapter;
currentPart = null;
currentImageItem = item;
currentRow = null;
currentIndex = -1;
if (chapter) expandedChapters.add(chapter.id);
applyFilter(false);
closeQuickEditor(false);
currentMode = "reading";
renderAll();
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
}
function scrollRowIntoView(rowId) {
@@ -1094,7 +1302,7 @@ async function setMode(mode, saveBefore = true) {
const ok = await maybeSaveBeforeSwitch();
if (!ok) return;
}
if (mode === "polish" && isImageChapter(currentChapter) && !currentPart) {
if (mode === "polish" && (currentImageItem || isImageChapter(currentChapter)) && !currentPart) {
toast("插图章节没有可编辑译文,已保持阅读模式。");
mode = "reading";
}
@@ -1105,7 +1313,7 @@ async function setMode(mode, saveBefore = true) {
if (mode === "polish") {
closeQuickEditor(false);
if (!scopeCoversRow(currentRow)) currentRow = null;
if (!currentRow) currentRow = scopeVisibleRows()[0] || scopeRows()[0] || filteredRows[0] || null;
if (!currentRow) currentRow = visibleRowsForScope()[0] || scopeRows()[0] || filteredRows[0] || null;
}
withReaderAnchor(() => renderAll());
if (mode === "reading" && currentRow) scrollRowIntoView(currentRow.id);
@@ -1114,8 +1322,11 @@ async function setMode(mode, saveBefore = true) {
function openQuickEditor(row) {
withReaderAnchor(() => {
currentRow = row;
currentIndex = filteredRows.findIndex((item) => item.id === row.id);
if (isImageChapter(currentChapter) && !currentPart) currentChapter = null;
currentImageItem = null;
syncScopeToRow(row);
applyFilter(false);
currentIndex = filteredRows.findIndex((item) => item.id === row.id);
fillQuickEditor(row);
dom.quickEditor.hidden = false;
document.body.classList.add("quickOpen");
@@ -1274,17 +1485,18 @@ function clearMarks() {
}
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));
const candidates = visibleRowsForScope();
if (!candidates.length) return;
const index = Math.max(0, candidates.findIndex((row) => currentRow && row.id === currentRow.id));
const next = Math.max(0, Math.min(candidates.length - 1, index + delta));
if (next === index) return;
await maybeSelectRow(filteredRows[next], { openEditor: currentMode === "polish", openQuick: currentMode === "reading" && !dom.quickEditor.hidden, scroll: true });
await maybeSelectRow(candidates[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 currentScopeId = activeChapterScopeId();
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;
@@ -1344,6 +1556,8 @@ function initEvents() {
currentIndex = -1;
applyFilter(true);
});
dom.searchScopeCurrentBtn.addEventListener("click", () => setSearchScope("current"));
dom.searchScopeBookBtn.addEventListener("click", () => setSearchScope("book"));
dom.tocPanelBtn.addEventListener("click", () => {
if (sidePanelMode === "toc") {
closeSidePanel();
+5 -1
View File
@@ -9,7 +9,7 @@
<body>
<header class="topbar">
<div class="brandBlock">
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.4.1</span></h1>
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.5.0</span></h1>
<p id="sessionMeta">正在载入……</p>
</div>
<div class="modeTabs" role="tablist" aria-label="审校模式">
@@ -75,6 +75,10 @@
<section class="searchPanel" id="searchPanel" hidden>
<div class="searchbox">
<input id="searchInput" type="search" placeholder="搜索原文、译文、备注">
<div class="searchScope" role="group" aria-label="检索范围">
<button id="searchScopeCurrentBtn" type="button" class="active">当前范围</button>
<button id="searchScopeBookBtn" type="button">全书全局</button>
</div>
</div>
<div class="stats" id="stats"></div>
<div class="rowList" id="rowList"></div>
+75 -4
View File
@@ -351,6 +351,8 @@ button.primary:hover,
.searchbox {
padding: 12px;
border-bottom: 1px solid var(--line);
display: grid;
gap: 8px;
}
.searchbox input {
@@ -362,6 +364,30 @@ button.primary:hover,
background: #ffffff;
}
.searchScope {
display: grid;
grid-template-columns: 1fr 1fr;
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
background: #ffffff;
}
.searchScope button {
min-height: 30px;
border: 0;
border-radius: 0;
background: transparent;
color: var(--muted);
padding: 3px 8px;
font-size: 13px;
}
.searchScope button.active {
background: var(--accent);
color: #ffffff;
}
.stats {
padding: 8px 12px;
color: var(--muted);
@@ -432,7 +458,8 @@ button.primary:hover,
}
.tocChapterButton,
.tocPartButton {
.tocPartButton,
.tocImageButton {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -453,23 +480,37 @@ button.primary:hover,
font-weight: 600;
}
.tocPartButton {
.tocPartButton,
.tocImageButton {
margin-left: 24px;
width: calc(100% - 24px);
color: var(--muted);
}
.tocImageButton {
padding-left: 14px;
font-size: 13px;
}
.tocImageButton .tocTitle::before {
content: "插图 · ";
color: var(--accent);
font-weight: 600;
}
.tocChapterRow:hover,
.tocChapterRow.active,
.tocChapterButton:hover,
.tocToggle:hover,
.tocPartButton:hover {
.tocPartButton:hover,
.tocImageButton:hover {
background: #eef3f6;
}
.tocChapterRow.active,
.tocChapterButton.active,
.tocPartButton.active {
.tocPartButton.active,
.tocImageButton.active {
background: var(--accent-soft);
color: var(--accent-strong);
}
@@ -524,6 +565,15 @@ button.primary:hover,
color: var(--warn);
}
.emptyRowItem {
color: var(--muted);
cursor: default;
}
.emptyRowItem:hover {
background: transparent;
}
.imageRowItem {
cursor: default;
}
@@ -570,6 +620,11 @@ button.primary:hover,
background: #f4f6f8;
}
.readerPane,
.editorPane {
transition: margin-left 160ms ease;
}
.readerHeader {
position: sticky;
top: 0;
@@ -667,6 +722,11 @@ button.primary:hover,
text-align: center;
}
.inlineImageFigure {
margin: 18px auto 30px;
padding-top: 8px;
}
.imageChapterFigure img {
display: block;
max-width: 100%;
@@ -973,6 +1033,17 @@ textarea {
display: none !important;
}
@media (min-width: 1041px) {
body.sidebarOpen .readerPane,
body.sidebarOpen .editorPane {
margin-left: 380px;
}
body.sidebarOpen .sidebar {
box-shadow: none;
}
}
@media (min-width: 1320px) {
body.quickOpen .readingFlow {
margin-left: 56px;
+1 -1
View File
@@ -1,4 +1,4 @@
version = "0.4.1"
version = "0.5.0"
VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修",