mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Implement EPUB reader modal with navigation and audio playback features
This commit is contained in:
+385
-11
@@ -4,13 +4,17 @@ import io
|
|||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
import posixpath
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from html.parser import HTMLParser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
@@ -96,6 +100,281 @@ _SPEAKER_MODE_VALUES = {option["value"] for option in _SPEAKER_MODE_OPTIONS}
|
|||||||
|
|
||||||
|
|
||||||
_DEFAULT_ANALYSIS_THRESHOLD = 3
|
_DEFAULT_ANALYSIS_THRESHOLD = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_path(value: Any) -> Optional[Path]:
|
||||||
|
if isinstance(value, Path):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
candidate = Path(value)
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_epub_path(base_dir: str, href: str) -> str:
|
||||||
|
if not href:
|
||||||
|
return ""
|
||||||
|
sanitized = href.split("#", 1)[0].split("?", 1)[0].strip()
|
||||||
|
sanitized = sanitized.replace("\\", "/")
|
||||||
|
if not sanitized:
|
||||||
|
return ""
|
||||||
|
if sanitized.startswith("/"):
|
||||||
|
sanitized = sanitized[1:]
|
||||||
|
base_dir = ""
|
||||||
|
base = base_dir.strip("/")
|
||||||
|
combined = posixpath.join(base, sanitized) if base else sanitized
|
||||||
|
normalized = posixpath.normpath(combined)
|
||||||
|
if normalized in {"", "."}:
|
||||||
|
return ""
|
||||||
|
if normalized.startswith("../") or normalized == "..":
|
||||||
|
return ""
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_text(payload: bytes) -> str:
|
||||||
|
for encoding in ("utf-8", "utf-16", "windows-1252"):
|
||||||
|
try:
|
||||||
|
return payload.decode(encoding)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
return payload.decode("utf-8", "ignore")
|
||||||
|
|
||||||
|
|
||||||
|
class _NavMapParser(HTMLParser):
|
||||||
|
def __init__(self, base_dir: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._base_dir = base_dir
|
||||||
|
self._in_nav = False
|
||||||
|
self._nav_depth = 0
|
||||||
|
self._current_href: Optional[str] = None
|
||||||
|
self._buffer: List[str] = []
|
||||||
|
self.links: Dict[str, str] = {}
|
||||||
|
|
||||||
|
def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
|
||||||
|
tag_lower = tag.lower()
|
||||||
|
if tag_lower == "nav":
|
||||||
|
attributes = dict(attrs)
|
||||||
|
nav_type = (attributes.get("epub:type") or attributes.get("type") or "").strip().lower()
|
||||||
|
nav_role = (attributes.get("role") or "").strip().lower()
|
||||||
|
type_tokens = {token.strip() for token in nav_type.split() if token}
|
||||||
|
role_tokens = {token.strip() for token in nav_role.split() if token}
|
||||||
|
if "toc" in type_tokens or "doc-toc" in role_tokens:
|
||||||
|
self._in_nav = True
|
||||||
|
self._nav_depth = 1
|
||||||
|
return
|
||||||
|
if self._in_nav:
|
||||||
|
self._nav_depth += 1
|
||||||
|
return
|
||||||
|
if not self._in_nav:
|
||||||
|
return
|
||||||
|
if tag_lower == "a":
|
||||||
|
attributes = dict(attrs)
|
||||||
|
href = attributes.get("href") or ""
|
||||||
|
normalized = _normalize_epub_path(self._base_dir, href)
|
||||||
|
if normalized:
|
||||||
|
self._current_href = normalized
|
||||||
|
self._buffer = []
|
||||||
|
|
||||||
|
def handle_endtag(self, tag: str) -> None:
|
||||||
|
tag_lower = tag.lower()
|
||||||
|
if tag_lower == "nav" and self._in_nav:
|
||||||
|
self._nav_depth -= 1
|
||||||
|
if self._nav_depth <= 0:
|
||||||
|
self._in_nav = False
|
||||||
|
return
|
||||||
|
if not self._in_nav:
|
||||||
|
return
|
||||||
|
if tag_lower == "a" and self._current_href:
|
||||||
|
text = "".join(self._buffer).strip()
|
||||||
|
if text:
|
||||||
|
self.links.setdefault(self._current_href, text)
|
||||||
|
self._current_href = None
|
||||||
|
self._buffer = []
|
||||||
|
|
||||||
|
def handle_data(self, data: str) -> None:
|
||||||
|
if self._in_nav and self._current_href and data:
|
||||||
|
self._buffer.append(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_nav_document(payload: bytes, base_dir: str) -> Dict[str, str]:
|
||||||
|
parser = _NavMapParser(base_dir)
|
||||||
|
parser.feed(_decode_text(payload))
|
||||||
|
parser.close()
|
||||||
|
return parser.links
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ncx_document(payload: bytes, base_dir: str) -> Dict[str, str]:
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(payload)
|
||||||
|
except ET.ParseError:
|
||||||
|
return {}
|
||||||
|
nav_map: Dict[str, str] = {}
|
||||||
|
for nav_point in root.findall(".//{*}navPoint"):
|
||||||
|
content = nav_point.find(".//{*}content")
|
||||||
|
if content is None:
|
||||||
|
continue
|
||||||
|
src = content.attrib.get("src", "")
|
||||||
|
normalized = _normalize_epub_path(base_dir, src)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
label_el = nav_point.find(".//{*}text")
|
||||||
|
label = (label_el.text or "").strip() if label_el is not None and label_el.text else ""
|
||||||
|
if not label:
|
||||||
|
label = posixpath.basename(normalized) or f"Section {len(nav_map) + 1}"
|
||||||
|
nav_map.setdefault(normalized, label)
|
||||||
|
return nav_map
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_epub_chapters(epub_path: Path) -> List[Dict[str, str]]:
|
||||||
|
chapters: List[Dict[str, str]] = []
|
||||||
|
if not epub_path or not epub_path.exists():
|
||||||
|
return chapters
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(epub_path, "r") as archive:
|
||||||
|
container_bytes = archive.read("META-INF/container.xml")
|
||||||
|
container_root = ET.fromstring(container_bytes)
|
||||||
|
rootfile = container_root.find(".//{*}rootfile")
|
||||||
|
if rootfile is None:
|
||||||
|
return chapters
|
||||||
|
opf_path = (rootfile.attrib.get("full-path") or "").strip()
|
||||||
|
if not opf_path:
|
||||||
|
return chapters
|
||||||
|
opf_dir = posixpath.dirname(opf_path)
|
||||||
|
opf_bytes = archive.read(opf_path)
|
||||||
|
opf_root = ET.fromstring(opf_bytes)
|
||||||
|
|
||||||
|
manifest: Dict[str, Dict[str, str]] = {}
|
||||||
|
for item in opf_root.findall(".//{*}manifest/{*}item"):
|
||||||
|
item_id = item.attrib.get("id")
|
||||||
|
href = item.attrib.get("href")
|
||||||
|
if not item_id or not href:
|
||||||
|
continue
|
||||||
|
manifest[item_id] = {
|
||||||
|
"href": _normalize_epub_path(opf_dir, href),
|
||||||
|
"properties": item.attrib.get("properties", ""),
|
||||||
|
"media_type": item.attrib.get("media-type", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
spine_hrefs: List[str] = []
|
||||||
|
nav_id: Optional[str] = None
|
||||||
|
spine = opf_root.find(".//{*}spine")
|
||||||
|
if spine is not None:
|
||||||
|
nav_id = spine.attrib.get("toc")
|
||||||
|
for itemref in spine.findall(".//{*}itemref"):
|
||||||
|
idref = itemref.attrib.get("idref")
|
||||||
|
if not idref:
|
||||||
|
continue
|
||||||
|
entry = manifest.get(idref)
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
href = entry["href"]
|
||||||
|
if href and href not in spine_hrefs:
|
||||||
|
spine_hrefs.append(href)
|
||||||
|
|
||||||
|
nav_href: Optional[str] = None
|
||||||
|
for entry in manifest.values():
|
||||||
|
properties = entry.get("properties") or ""
|
||||||
|
if "nav" in {token.strip() for token in properties.split() if token}:
|
||||||
|
nav_href = entry["href"]
|
||||||
|
break
|
||||||
|
if not nav_href and nav_id:
|
||||||
|
toc_entry = manifest.get(nav_id)
|
||||||
|
if toc_entry:
|
||||||
|
nav_href = toc_entry["href"]
|
||||||
|
|
||||||
|
nav_titles: Dict[str, str] = {}
|
||||||
|
if nav_href:
|
||||||
|
nav_base = posixpath.dirname(nav_href)
|
||||||
|
try:
|
||||||
|
nav_bytes = archive.read(nav_href)
|
||||||
|
except KeyError:
|
||||||
|
nav_bytes = None
|
||||||
|
if nav_bytes is not None:
|
||||||
|
if nav_href.lower().endswith(".ncx"):
|
||||||
|
nav_titles = _parse_ncx_document(nav_bytes, nav_base)
|
||||||
|
else:
|
||||||
|
nav_titles = _parse_nav_document(nav_bytes, nav_base)
|
||||||
|
|
||||||
|
if not nav_titles and nav_id and nav_id in manifest:
|
||||||
|
toc_entry = manifest[nav_id]
|
||||||
|
nav_base = posixpath.dirname(toc_entry["href"])
|
||||||
|
try:
|
||||||
|
nav_bytes = archive.read(toc_entry["href"])
|
||||||
|
except KeyError:
|
||||||
|
nav_bytes = None
|
||||||
|
if nav_bytes is not None:
|
||||||
|
nav_titles = _parse_ncx_document(nav_bytes, nav_base)
|
||||||
|
|
||||||
|
for index, href in enumerate(spine_hrefs, start=1):
|
||||||
|
normalized = _normalize_epub_path("", href)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
title = (
|
||||||
|
nav_titles.get(normalized)
|
||||||
|
or nav_titles.get(normalized.split("#", 1)[0])
|
||||||
|
or posixpath.basename(normalized)
|
||||||
|
or f"Chapter {index}"
|
||||||
|
)
|
||||||
|
chapters.append({"href": normalized, "title": title})
|
||||||
|
|
||||||
|
if not chapters and nav_titles:
|
||||||
|
for index, (href, title) in enumerate(nav_titles.items(), start=1):
|
||||||
|
normalized = _normalize_epub_path("", href)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
label = title or posixpath.basename(normalized) or f"Chapter {index}"
|
||||||
|
chapters.append({"href": normalized, "title": label})
|
||||||
|
|
||||||
|
return chapters
|
||||||
|
except (FileNotFoundError, zipfile.BadZipFile, KeyError, ET.ParseError, UnicodeDecodeError):
|
||||||
|
return []
|
||||||
|
return chapters
|
||||||
|
|
||||||
|
|
||||||
|
def _read_epub_bytes(epub_path: Path, raw_href: str) -> bytes:
|
||||||
|
normalized = _normalize_epub_path("", raw_href)
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("Invalid resource path")
|
||||||
|
with zipfile.ZipFile(epub_path, "r") as archive:
|
||||||
|
return archive.read(normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def _locate_job_epub(job: Job) -> Optional[Path]:
|
||||||
|
result = getattr(job, "result", None)
|
||||||
|
if result is not None:
|
||||||
|
primary = _coerce_path(getattr(result, "epub_path", None))
|
||||||
|
if primary and primary.exists():
|
||||||
|
return primary
|
||||||
|
artifacts = getattr(result, "artifacts", None)
|
||||||
|
if isinstance(artifacts, Mapping):
|
||||||
|
for value in artifacts.values():
|
||||||
|
candidate = _coerce_path(value)
|
||||||
|
if candidate and candidate.suffix.lower() == ".epub" and candidate.exists():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _locate_job_audio(job: Job) -> Optional[Path]:
|
||||||
|
result = getattr(job, "result", None)
|
||||||
|
candidates: List[Path] = []
|
||||||
|
if result is not None:
|
||||||
|
primary = _coerce_path(getattr(result, "audio_path", None))
|
||||||
|
if primary:
|
||||||
|
candidates.append(primary)
|
||||||
|
artifacts = getattr(result, "artifacts", None)
|
||||||
|
if isinstance(artifacts, Mapping):
|
||||||
|
for value in artifacts.values():
|
||||||
|
candidate = _coerce_path(value)
|
||||||
|
if candidate:
|
||||||
|
candidates.append(candidate)
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate and candidate.exists() and candidate.suffix.lower() in {".mp3", ".wav", ".flac", ".ogg", ".opus", ".m4a"}:
|
||||||
|
return candidate
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate and candidate.exists():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
def _build_narrator_roster(
|
def _build_narrator_roster(
|
||||||
voice: str,
|
voice: str,
|
||||||
voice_profile: Optional[str],
|
voice_profile: Optional[str],
|
||||||
@@ -2288,26 +2567,121 @@ def clear_finished_jobs() -> ResponseReturnValue:
|
|||||||
return redirect(url_for("web.index", _anchor="queue"))
|
return redirect(url_for("web.index", _anchor="queue"))
|
||||||
|
|
||||||
|
|
||||||
|
@web_bp.get("/jobs/<job_id>/epub")
|
||||||
|
def job_epub(job_id: str) -> ResponseReturnValue:
|
||||||
|
job = _service().get_job(job_id)
|
||||||
|
if job is None or job.status != JobStatus.COMPLETED:
|
||||||
|
abort(404)
|
||||||
|
epub_path = _locate_job_epub(job)
|
||||||
|
if not epub_path:
|
||||||
|
abort(404)
|
||||||
|
return send_file(
|
||||||
|
epub_path,
|
||||||
|
mimetype="application/epub+zip",
|
||||||
|
as_attachment=False,
|
||||||
|
download_name=epub_path.name,
|
||||||
|
conditional=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@web_bp.get("/jobs/<job_id>/audio-stream")
|
||||||
|
def job_audio_stream(job_id: str) -> ResponseReturnValue:
|
||||||
|
job = _service().get_job(job_id)
|
||||||
|
if job is None or job.status != JobStatus.COMPLETED:
|
||||||
|
abort(404)
|
||||||
|
audio_path = _locate_job_audio(job)
|
||||||
|
if not audio_path:
|
||||||
|
abort(404)
|
||||||
|
mime_type, _ = mimetypes.guess_type(str(audio_path))
|
||||||
|
return send_file(
|
||||||
|
audio_path,
|
||||||
|
mimetype=mime_type or "audio/mpeg",
|
||||||
|
as_attachment=False,
|
||||||
|
conditional=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@web_bp.get("/jobs/<job_id>/reader")
|
||||||
|
def job_reader(job_id: str) -> ResponseReturnValue:
|
||||||
|
job = _service().get_job(job_id)
|
||||||
|
if job is None or job.status != JobStatus.COMPLETED:
|
||||||
|
abort(404)
|
||||||
|
epub_path = _locate_job_epub(job)
|
||||||
|
if not epub_path:
|
||||||
|
abort(404)
|
||||||
|
chapters = _extract_epub_chapters(epub_path)
|
||||||
|
audio_path = _locate_job_audio(job)
|
||||||
|
chapter_url = url_for("web.job_reader_chapter", job_id=job.id)
|
||||||
|
asset_base = url_for("web.job_reader_asset", job_id=job.id, asset_path="").rstrip("/") + "/"
|
||||||
|
audio_url = url_for("web.job_audio_stream", job_id=job.id) if audio_path else ""
|
||||||
|
epub_url = url_for("web.job_epub", job_id=job.id)
|
||||||
|
return render_template(
|
||||||
|
"reader_embed.html",
|
||||||
|
job=job,
|
||||||
|
audio_url=audio_url,
|
||||||
|
epub_url=epub_url,
|
||||||
|
chapters=chapters,
|
||||||
|
chapter_url=chapter_url,
|
||||||
|
asset_base=asset_base,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@web_bp.get("/jobs/<job_id>/reader/chapter")
|
||||||
|
def job_reader_chapter(job_id: str) -> ResponseReturnValue:
|
||||||
|
job = _service().get_job(job_id)
|
||||||
|
if job is None or job.status != JobStatus.COMPLETED:
|
||||||
|
abort(404)
|
||||||
|
epub_path = _locate_job_epub(job)
|
||||||
|
if not epub_path:
|
||||||
|
abort(404)
|
||||||
|
raw_href = request.args.get("href", "").strip()
|
||||||
|
if not raw_href:
|
||||||
|
abort(400)
|
||||||
|
try:
|
||||||
|
chapter_bytes = _read_epub_bytes(epub_path, raw_href)
|
||||||
|
except (ValueError, FileNotFoundError, KeyError):
|
||||||
|
abort(404)
|
||||||
|
content = _decode_text(chapter_bytes)
|
||||||
|
return jsonify({"content": content})
|
||||||
|
|
||||||
|
|
||||||
|
@web_bp.get("/jobs/<job_id>/reader/asset/<path:asset_path>")
|
||||||
|
def job_reader_asset(job_id: str, asset_path: str) -> ResponseReturnValue:
|
||||||
|
job = _service().get_job(job_id)
|
||||||
|
if job is None or job.status != JobStatus.COMPLETED:
|
||||||
|
abort(404)
|
||||||
|
epub_path = _locate_job_epub(job)
|
||||||
|
if not epub_path:
|
||||||
|
abort(404)
|
||||||
|
try:
|
||||||
|
payload = _read_epub_bytes(epub_path, asset_path)
|
||||||
|
except (ValueError, FileNotFoundError, KeyError):
|
||||||
|
abort(404)
|
||||||
|
mime_type, _ = mimetypes.guess_type(asset_path)
|
||||||
|
buffer = io.BytesIO(payload)
|
||||||
|
buffer.seek(0)
|
||||||
|
return send_file(
|
||||||
|
buffer,
|
||||||
|
mimetype=mime_type or "application/octet-stream",
|
||||||
|
as_attachment=False,
|
||||||
|
download_name=posixpath.basename(asset_path) or "asset",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@web_bp.get("/jobs/<job_id>/download")
|
@web_bp.get("/jobs/<job_id>/download")
|
||||||
def download_job(job_id: str) -> ResponseReturnValue:
|
def download_job(job_id: str) -> ResponseReturnValue:
|
||||||
job = _service().get_job(job_id)
|
job = _service().get_job(job_id)
|
||||||
if job is None or job.status != JobStatus.COMPLETED:
|
if job is None or job.status != JobStatus.COMPLETED:
|
||||||
abort(404)
|
abort(404)
|
||||||
result = getattr(job, "result", None)
|
audio_path = _locate_job_audio(job)
|
||||||
audio_path = getattr(result, "audio_path", None)
|
if not audio_path:
|
||||||
if audio_path is None:
|
|
||||||
abort(404)
|
abort(404)
|
||||||
if not isinstance(audio_path, Path): # pragma: no cover - sanity guard
|
mime_type, _ = mimetypes.guess_type(str(audio_path))
|
||||||
abort(404)
|
|
||||||
audio_path_path = cast(Path, audio_path)
|
|
||||||
if not audio_path_path.exists():
|
|
||||||
abort(404)
|
|
||||||
mime_type, _ = mimetypes.guess_type(str(audio_path_path))
|
|
||||||
return send_file(
|
return send_file(
|
||||||
audio_path_path,
|
audio_path,
|
||||||
mimetype=mime_type or "application/octet-stream",
|
mimetype=mime_type or "application/octet-stream",
|
||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
download_name=audio_path_path.name,
|
download_name=audio_path.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
const initDashboard = () => {
|
const initDashboard = () => {
|
||||||
const uploadModal = document.querySelector('[data-role="upload-modal"]');
|
const uploadModal = document.querySelector('[data-role="upload-modal"]');
|
||||||
const openModalButtons = document.querySelectorAll('[data-role="open-upload-modal"]');
|
const openModalButtons = document.querySelectorAll('[data-role="open-upload-modal"]');
|
||||||
|
const readerModal = document.querySelector('[data-role="reader-modal"]');
|
||||||
|
const readerFrame = readerModal?.querySelector('[data-role="reader-frame"]') || null;
|
||||||
|
const readerHint = readerModal?.querySelector('[data-role="reader-modal-hint"]') || null;
|
||||||
|
const readerTitle = readerModal?.querySelector('#reader-modal-title') || null;
|
||||||
|
const defaultReaderHint = readerHint?.textContent || "";
|
||||||
const scope = uploadModal || document;
|
const scope = uploadModal || document;
|
||||||
|
|
||||||
const profileSelect = scope.querySelector('[data-role="voice-profile"]');
|
const profileSelect = scope.querySelector('[data-role="voice-profile"]');
|
||||||
@@ -11,6 +16,7 @@ const initDashboard = () => {
|
|||||||
const languageSelect = uploadModal?.querySelector("#language") || document.getElementById("language");
|
const languageSelect = uploadModal?.querySelector("#language") || document.getElementById("language");
|
||||||
|
|
||||||
let lastTrigger = null;
|
let lastTrigger = null;
|
||||||
|
let readerTrigger = null;
|
||||||
|
|
||||||
const openUploadModal = (trigger) => {
|
const openUploadModal = (trigger) => {
|
||||||
if (!uploadModal) return;
|
if (!uploadModal) return;
|
||||||
@@ -36,6 +42,51 @@ const initDashboard = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openReaderModal = (trigger) => {
|
||||||
|
if (!readerModal || !readerFrame) return;
|
||||||
|
const url = trigger?.dataset.readerUrl || "";
|
||||||
|
if (!url) return;
|
||||||
|
readerTrigger = trigger || null;
|
||||||
|
const bookTitle = trigger?.dataset.bookTitle || "";
|
||||||
|
if (readerTitle) {
|
||||||
|
readerTitle.textContent = bookTitle ? `${bookTitle} · reader` : "Read & listen";
|
||||||
|
}
|
||||||
|
if (readerHint) {
|
||||||
|
readerHint.textContent = bookTitle ? `Preview ${bookTitle} directly in your browser.` : defaultReaderHint;
|
||||||
|
}
|
||||||
|
closeUploadModal();
|
||||||
|
readerModal.hidden = false;
|
||||||
|
readerModal.dataset.open = "true";
|
||||||
|
document.body.classList.add("modal-open");
|
||||||
|
readerFrame.src = url;
|
||||||
|
try {
|
||||||
|
readerFrame.focus({ preventScroll: true });
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore focus errors when the browser blocks iframe focus
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeReaderModal = () => {
|
||||||
|
if (!readerModal) return;
|
||||||
|
if (readerModal.hidden) return;
|
||||||
|
readerModal.hidden = true;
|
||||||
|
delete readerModal.dataset.open;
|
||||||
|
document.body.classList.remove("modal-open");
|
||||||
|
if (readerFrame) {
|
||||||
|
readerFrame.src = "about:blank";
|
||||||
|
}
|
||||||
|
if (readerHint) {
|
||||||
|
readerHint.textContent = defaultReaderHint;
|
||||||
|
}
|
||||||
|
if (readerTitle) {
|
||||||
|
readerTitle.textContent = "Read & listen";
|
||||||
|
}
|
||||||
|
if (readerTrigger && readerTrigger instanceof HTMLElement) {
|
||||||
|
readerTrigger.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
readerTrigger = null;
|
||||||
|
};
|
||||||
|
|
||||||
openModalButtons.forEach((button) => {
|
openModalButtons.forEach((button) => {
|
||||||
button.addEventListener("click", (event) => {
|
button.addEventListener("click", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -54,8 +105,29 @@ const initDashboard = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("keydown", (event) => {
|
document.addEventListener("keydown", (event) => {
|
||||||
if (event.key === "Escape" && uploadModal && !uploadModal.hidden) {
|
if (event.key === "Escape") {
|
||||||
closeUploadModal();
|
if (uploadModal && !uploadModal.hidden) {
|
||||||
|
closeUploadModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (readerModal && !readerModal.hidden) {
|
||||||
|
closeReaderModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const readerClose = event.target.closest('[data-role="reader-modal-close"]');
|
||||||
|
if (readerClose) {
|
||||||
|
event.preventDefault();
|
||||||
|
closeReaderModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const readerTriggerBtn = event.target.closest('[data-role="open-reader"]');
|
||||||
|
if (readerTriggerBtn) {
|
||||||
|
event.preventDefault();
|
||||||
|
openReaderModal(readerTriggerBtn);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,29 @@ body {
|
|||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reader-modal {
|
||||||
|
width: min(960px, 95vw);
|
||||||
|
height: min(90vh, 720px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-modal__body {
|
||||||
|
padding: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(15, 23, 42, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-modal__frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
.voice-browser__header {
|
.voice-browser__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -137,6 +137,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" data-role="reader-modal" hidden>
|
||||||
|
<div class="modal__overlay" data-role="reader-modal-close" tabindex="-1"></div>
|
||||||
|
<div class="modal__content card card--modal reader-modal" role="dialog" aria-modal="true" aria-labelledby="reader-modal-title">
|
||||||
|
<header class="modal__header">
|
||||||
|
<div class="modal__heading">
|
||||||
|
<h2 class="modal__title" id="reader-modal-title">Read & listen</h2>
|
||||||
|
<p class="hint" data-role="reader-modal-hint">Preview synchronized narration directly in your browser.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="icon-button" data-role="reader-modal-close" aria-label="Close reader">✕</button>
|
||||||
|
</header>
|
||||||
|
<div class="modal__body reader-modal__body">
|
||||||
|
<iframe title="EPUB reader"
|
||||||
|
class="reader-modal__frame"
|
||||||
|
data-role="reader-frame"
|
||||||
|
allow="autoplay"
|
||||||
|
loading="lazy"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="card" id="queue">
|
<section class="card" id="queue">
|
||||||
<div id="jobs-panel"
|
<div id="jobs-panel"
|
||||||
hx-get="{{ url_for('web.jobs_partial') }}"
|
hx-get="{{ url_for('web.jobs_partial') }}"
|
||||||
|
|||||||
@@ -75,6 +75,25 @@
|
|||||||
{% if job.status == JobStatus.COMPLETED and job.result.audio_path %}
|
{% if job.status == JobStatus.COMPLETED and job.result.audio_path %}
|
||||||
<a class="button button--ghost" href="{{ url_for('web.download_job', job_id=job.id) }}">Download</a>
|
<a class="button button--ghost" href="{{ url_for('web.download_job', job_id=job.id) }}">Download</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% set reader_source = None %}
|
||||||
|
{% if job.status == JobStatus.COMPLETED and job.result %}
|
||||||
|
{% if job.result.epub_path %}
|
||||||
|
{% set reader_source = job.result.epub_path %}
|
||||||
|
{% elif job.result.artifacts and job.result.artifacts.get('epub3') %}
|
||||||
|
{% set reader_source = job.result.artifacts.get('epub3') %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if reader_source %}
|
||||||
|
<button type="button"
|
||||||
|
class="icon-button"
|
||||||
|
data-role="open-reader"
|
||||||
|
data-reader-url="{{ url_for('web.job_reader', job_id=job.id) }}"
|
||||||
|
data-book-title="{{ job.original_filename }}"
|
||||||
|
title="Open reader"
|
||||||
|
aria-label="Open EPUB reader for {{ job.original_filename }}">
|
||||||
|
📖
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.delete_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Remove this job from the list?">Remove</button>
|
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.delete_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Remove this job from the list?">Remove</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ job.original_filename }} · Reader</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #020617;
|
||||||
|
--panel: rgba(15, 23, 42, 0.92);
|
||||||
|
--border: rgba(148, 163, 184, 0.25);
|
||||||
|
--text: #e2e8f0;
|
||||||
|
--accent: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar__title {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(30, 41, 59, 0.8);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar button:hover,
|
||||||
|
.reader-toolbar button:focus-visible {
|
||||||
|
background: rgba(56, 189, 248, 0.2);
|
||||||
|
border-color: rgba(56, 189, 248, 0.4);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar select {
|
||||||
|
flex: 1 1 30%;
|
||||||
|
min-width: 140px;
|
||||||
|
max-width: 320px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(30, 41, 59, 0.8);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar select:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#reader-container {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
#reader {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-footer {
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-footer audio {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-status {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(226, 232, 240, 0.75);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
data-chapter-url="{{ chapter_url }}"
|
||||||
|
data-asset-base="{{ asset_base }}"
|
||||||
|
>
|
||||||
|
<div class="reader-shell" data-role="reader-shell">
|
||||||
|
<div class="reader-toolbar">
|
||||||
|
<button type="button" data-action="prev">Previous</button>
|
||||||
|
<select data-role="reader-chapter" aria-label="Select chapter"></select>
|
||||||
|
<div class="reader-toolbar__title" data-role="reader-title">{{ job.original_filename }}</div>
|
||||||
|
<button type="button" data-action="next">Next</button>
|
||||||
|
</div>
|
||||||
|
<div id="reader-container">
|
||||||
|
<div id="reader"></div>
|
||||||
|
</div>
|
||||||
|
<div class="reader-footer">
|
||||||
|
{% if audio_url %}
|
||||||
|
<audio controls preload="metadata" src="{{ audio_url }}"></audio>
|
||||||
|
{% endif %}
|
||||||
|
<div class="reader-status" data-role="reader-status">Loading EPUB…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="application/json" id="reader-data">{{ {'chapters': chapters, 'title': job.original_filename}|tojson }}</script>
|
||||||
|
<script type="module">
|
||||||
|
const statusEl = document.querySelector('[data-role="reader-status"]');
|
||||||
|
const prevBtn = document.querySelector('[data-action="prev"]');
|
||||||
|
const nextBtn = document.querySelector('[data-action="next"]');
|
||||||
|
const titleEl = document.querySelector('[data-role="reader-title"]');
|
||||||
|
const readerEl = document.getElementById('reader');
|
||||||
|
const chapterSelect = document.querySelector('[data-role="reader-chapter"]');
|
||||||
|
const rawData = document.getElementById('reader-data');
|
||||||
|
const chapterUrl = document.body.dataset.chapterUrl || '';
|
||||||
|
const assetBase = document.body.dataset.assetBase || '';
|
||||||
|
|
||||||
|
let data = { chapters: [] };
|
||||||
|
try {
|
||||||
|
data = JSON.parse(rawData?.textContent || '{}');
|
||||||
|
} catch (error) {
|
||||||
|
data = { chapters: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapters = Array.isArray(data.chapters) ? data.chapters : [];
|
||||||
|
const baseTitle = typeof data.title === 'string' && data.title ? data.title : 'Book';
|
||||||
|
let currentIndex = 0;
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const activeStyleNodes = [];
|
||||||
|
|
||||||
|
const escapeFragment = (value) => {
|
||||||
|
if (!value) return '';
|
||||||
|
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
||||||
|
return CSS.escape(value);
|
||||||
|
}
|
||||||
|
return value.replace(/[^a-zA-Z0-9_-]/g, '\\$&');
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatus = (message) => {
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveAsset = (value) => {
|
||||||
|
if (!value) return value;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (/^(?:[a-z]+:)?\/\//i.test(trimmed) || trimmed.startsWith('data:')) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
const base = window.location.origin + assetBase;
|
||||||
|
try {
|
||||||
|
const url = new URL(trimmed, base);
|
||||||
|
return url.pathname + url.search + url.hash;
|
||||||
|
} catch (error) {
|
||||||
|
const normalized = trimmed.replace(/^\.\//, '').replace(/^\/+/, '');
|
||||||
|
return `${assetBase}${normalized}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rewriteAssets = (root) => {
|
||||||
|
root.querySelectorAll('script').forEach((node) => node.remove());
|
||||||
|
root.querySelectorAll('[src]').forEach((node) => {
|
||||||
|
const resolved = resolveAsset(node.getAttribute('src'));
|
||||||
|
if (resolved) {
|
||||||
|
node.setAttribute('src', resolved);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
root.querySelectorAll('link[href]').forEach((node) => {
|
||||||
|
const resolved = resolveAsset(node.getAttribute('href'));
|
||||||
|
if (resolved) {
|
||||||
|
node.setAttribute('href', resolved);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyStyles = (root) => {
|
||||||
|
while (activeStyleNodes.length) {
|
||||||
|
const node = activeStyleNodes.pop();
|
||||||
|
node?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
root.querySelectorAll('style').forEach((styleNode) => {
|
||||||
|
const clone = styleNode.cloneNode(true);
|
||||||
|
document.head.append(clone);
|
||||||
|
activeStyleNodes.push(clone);
|
||||||
|
styleNode.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
root.querySelectorAll('link[rel="stylesheet"]').forEach((linkNode) => {
|
||||||
|
const href = linkNode.getAttribute('href');
|
||||||
|
if (!href) {
|
||||||
|
linkNode.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clone = document.createElement('link');
|
||||||
|
clone.rel = 'stylesheet';
|
||||||
|
clone.href = href;
|
||||||
|
document.head.append(clone);
|
||||||
|
activeStyleNodes.push(clone);
|
||||||
|
linkNode.remove();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateControls = () => {
|
||||||
|
const hasChapters = chapters.length > 0;
|
||||||
|
if (prevBtn) {
|
||||||
|
prevBtn.disabled = !hasChapters || currentIndex <= 0;
|
||||||
|
}
|
||||||
|
if (nextBtn) {
|
||||||
|
nextBtn.disabled = !hasChapters || currentIndex >= chapters.length - 1;
|
||||||
|
}
|
||||||
|
if (chapterSelect && hasChapters) {
|
||||||
|
chapterSelect.value = String(currentIndex);
|
||||||
|
}
|
||||||
|
if (titleEl) {
|
||||||
|
const current = chapters[currentIndex];
|
||||||
|
titleEl.textContent = current?.title ? `${current.title} · ${baseTitle}` : baseTitle;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderChapter = async (index, fragmentId = '') => {
|
||||||
|
if (!chapters[index]) {
|
||||||
|
setStatus('No chapter available.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus('Loading chapter…');
|
||||||
|
currentIndex = index;
|
||||||
|
updateControls();
|
||||||
|
try {
|
||||||
|
const target = new URL(chapterUrl, window.location.origin);
|
||||||
|
target.searchParams.set('href', chapters[index].href);
|
||||||
|
const response = await fetch(target.toString(), { credentials: 'same-origin' });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
const content = payload?.content || '';
|
||||||
|
const doc = parser.parseFromString(content, 'text/html');
|
||||||
|
rewriteAssets(doc);
|
||||||
|
applyStyles(doc);
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
const body = doc.body || doc.documentElement;
|
||||||
|
body.childNodes.forEach((node) => {
|
||||||
|
fragment.append(node.cloneNode(true));
|
||||||
|
});
|
||||||
|
readerEl?.replaceChildren(fragment);
|
||||||
|
if (readerEl) {
|
||||||
|
readerEl.scrollTop = 0;
|
||||||
|
if (fragmentId) {
|
||||||
|
const target = readerEl.querySelector(`#${escapeFragment(fragmentId)}`);
|
||||||
|
if (target instanceof HTMLElement) {
|
||||||
|
target.scrollIntoView({ block: 'start', behavior: 'auto' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStatus(`Chapter ${index + 1} of ${chapters.length}`);
|
||||||
|
updateControls();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load chapter', error);
|
||||||
|
setStatus('Unable to load this chapter. Try another one.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initChapters = () => {
|
||||||
|
chapterSelect?.replaceChildren();
|
||||||
|
if (!chapters.length) {
|
||||||
|
setStatus('No chapters found in this book.');
|
||||||
|
if (chapterSelect) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.textContent = 'No chapters';
|
||||||
|
option.value = '';
|
||||||
|
chapterSelect.append(option);
|
||||||
|
chapterSelect.disabled = true;
|
||||||
|
}
|
||||||
|
updateControls();
|
||||||
|
readerEl.textContent = 'This EPUB does not include any readable chapters.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chapterSelect?.removeAttribute('disabled');
|
||||||
|
chapters.forEach((chapter, idx) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = String(idx);
|
||||||
|
option.textContent = chapter.title || `Chapter ${idx + 1}`;
|
||||||
|
chapterSelect?.append(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
chapterSelect?.addEventListener('change', (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLSelectElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = Number.parseInt(target.value, 10);
|
||||||
|
if (!Number.isNaN(value)) {
|
||||||
|
renderChapter(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
prevBtn?.addEventListener('click', () => {
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
renderChapter(currentIndex - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
nextBtn?.addEventListener('click', () => {
|
||||||
|
if (currentIndex < chapters.length - 1) {
|
||||||
|
renderChapter(currentIndex + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
renderChapter(currentIndex - 1);
|
||||||
|
}
|
||||||
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
if (currentIndex < chapters.length - 1) {
|
||||||
|
renderChapter(currentIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
renderChapter(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
initChapters();
|
||||||
|
|
||||||
|
readerEl?.addEventListener('click', (event) => {
|
||||||
|
const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null;
|
||||||
|
if (!anchor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const href = anchor.getAttribute('href') || '';
|
||||||
|
if (!href || href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (href.startsWith('#')) {
|
||||||
|
event.preventDefault();
|
||||||
|
const fragmentId = href.slice(1);
|
||||||
|
const target = readerEl.querySelector(`#${escapeFragment(fragmentId)}`);
|
||||||
|
if (target instanceof HTMLElement) {
|
||||||
|
target.scrollIntoView({ block: 'start', behavior: 'auto' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [resource, fragment] = href.split('#', 2);
|
||||||
|
const matchIndex = chapters.findIndex((chapter) => {
|
||||||
|
const normalized = (chapter.href || '').split('#', 1)[0];
|
||||||
|
return normalized === resource;
|
||||||
|
});
|
||||||
|
if (matchIndex >= 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
renderChapter(matchIndex, fragment || '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user