feat(editor): refine toc hierarchy and search scope
This commit is contained in:
@@ -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 更新,全局检索可跨章节返回并跳转。
|
||||
- 阅读模式不显示每段编号、快速编辑、快速标记按钮。
|
||||
- 点击正文段落打开右侧精修与重翻面板。
|
||||
- 右侧面板打开或关闭导致正文宽度变化时,应恢复用户原来正在看的段落位置。
|
||||
|
||||
@@ -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,对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
version = "0.4.1"
|
||||
version = "0.5.0"
|
||||
|
||||
VERSION_RULES = {
|
||||
"PATCH": "修复 bug 或兼容性小修",
|
||||
|
||||
Reference in New Issue
Block a user