feat(reader): send richer progress payload with chapter boundaries and zoom
The reader's saveProgress() now sends a more complete payload to the backend so ProgressService has more data for enrichment and merge: - chapter: computed from TOC boundary index instead of missing - reading_mode: current display mode (page, chapter, percent, time-left) - zoom_level: for fixed-layout books (renderer.zoomPercent / 100) - current_page: real page number for fixed-layout, location.current for reflowable - total_pages: section count for fixed-layout, location.total for reflowable Adds computeChapterPageBoundaries(doc) for reflowable EPUBs that maps TOC anchors to rendered page numbers, recomputes after fonts load. Adds computeFixedLayoutChapterBoundaries() for fixed-layout books that resolves TOC hrefs to page indices via view.resolveNavigation(). Updates reader.templ to expose isFixedLayout to Alpine init.
This commit is contained in:
@@ -154,7 +154,7 @@ templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
|
|||||||
<!-- Separator -->
|
<!-- Separator -->
|
||||||
<div class="w-px h-6 bg-gray-600 mx-1"></div>
|
<div class="w-px h-6 bg-gray-600 mx-1"></div>
|
||||||
<!-- Progress display -->
|
<!-- Progress display -->
|
||||||
<div id="progress-display" x-text="progressText" @click="cycleProgressMode()" title="Click to change progress mode" class="text-sm min-w-[4rem] text-center cursor-pointer">
|
<div id="progress-display" x-text="progressText" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[4rem] text-center cursor-pointer">
|
||||||
if progress.FormatGroup == "reflowable" {
|
if progress.FormatGroup == "reflowable" {
|
||||||
if metadata.EstimatedPages > 0 {
|
if metadata.EstimatedPages > 0 {
|
||||||
{ fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages) }
|
{ fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages) }
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</h1><button @click=\"toggleSettings()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Settings\">⚙️</button></div></div><!-- Bottom bar --><div class=\"fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t z-40\" style=\"background-color: var(--bg-primary);\"><div class=\"flex items-center px-2 py-2 gap-1\"><!-- Left navigation --><button @click=\"goLeft()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button><!-- Progress slider --><input id=\"progress-slider\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" list=\"tick-marks\" @input=\"goToFraction($event.target.value)\" class=\"grow\"> <datalist id=\"tick-marks\"></datalist><!-- Right navigation --><button @click=\"goRight()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 9 6 L 15 12 L 9 18\"></path></svg></button><!-- Separator --><div class=\"w-px h-6 bg-gray-600 mx-1\"></div><!-- Zoom controls (fixed-layout only) --><template x-if=\"isFixedLayout\"><div class=\"flex items-center gap-1\"><!-- Zoom out --><button @click=\"zoomOut()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Zoom Out\" aria-label=\"Zoom out\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 5 10 L 15 10\"></path></svg></button><!-- Zoom percentage --><button @click=\"resetZoom()\" class=\"text-xs px-1 rounded-lg hover:bg-gray-700 min-w-[3rem]\" title=\"Reset Zoom\" aria-label=\"Reset zoom\"><span x-text=\"zoomPercent + '%'\">100%</span></button><!-- Zoom in --><button @click=\"zoomIn()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Zoom In\" aria-label=\"Zoom in\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 10 5 L 10 15 M 5 10 L 15 10\"></path></svg></button><!-- Magnifier --><button @click=\"toggleMagnifier()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Magnifier\" aria-label=\"Toggle magnifier\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><circle cx=\"9\" cy=\"9\" r=\"5\"></circle> <path d=\"M 13 13 L 18 18\"></path></svg></button><!-- Pan/Select mode (PDF only) --><button x-show=\"isPDF\" @click=\"toggleInteractionMode()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Pan/Select Mode\" aria-label=\"Toggle pan/select mode\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 5 3 v 12 M 5 15 l -2 2 M 5 15 l 2 2 M 5 3 l 3 3\"></path></svg></button></div></template><!-- Separator --><div class=\"w-px h-6 bg-gray-600 mx-1\"></div><!-- Progress display --><div id=\"progress-display\" x-text=\"progressText\" @click=\"cycleProgressMode()\" title=\"Click to change progress mode\" class=\"text-sm min-w-[4rem] text-center cursor-pointer\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</h1><button @click=\"toggleSettings()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Settings\">⚙️</button></div></div><!-- Bottom bar --><div class=\"fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t z-40\" style=\"background-color: var(--bg-primary);\"><div class=\"flex items-center px-2 py-2 gap-1\"><!-- Left navigation --><button @click=\"goLeft()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button><!-- Progress slider --><input id=\"progress-slider\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" list=\"tick-marks\" @input=\"goToFraction($event.target.value)\" class=\"grow\"> <datalist id=\"tick-marks\"></datalist><!-- Right navigation --><button @click=\"goRight()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 9 6 L 15 12 L 9 18\"></path></svg></button><!-- Separator --><div class=\"w-px h-6 bg-gray-600 mx-1\"></div><!-- Zoom controls (fixed-layout only) --><template x-if=\"isFixedLayout\"><div class=\"flex items-center gap-1\"><!-- Zoom out --><button @click=\"zoomOut()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Zoom Out\" aria-label=\"Zoom out\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 5 10 L 15 10\"></path></svg></button><!-- Zoom percentage --><button @click=\"resetZoom()\" class=\"text-xs px-1 rounded-lg hover:bg-gray-700 min-w-[3rem]\" title=\"Reset Zoom\" aria-label=\"Reset zoom\"><span x-text=\"zoomPercent + '%'\">100%</span></button><!-- Zoom in --><button @click=\"zoomIn()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Zoom In\" aria-label=\"Zoom in\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 10 5 L 10 15 M 5 10 L 15 10\"></path></svg></button><!-- Magnifier --><button @click=\"toggleMagnifier()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Magnifier\" aria-label=\"Toggle magnifier\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><circle cx=\"9\" cy=\"9\" r=\"5\"></circle> <path d=\"M 13 13 L 18 18\"></path></svg></button><!-- Pan/Select mode (PDF only) --><button x-show=\"isPDF\" @click=\"toggleInteractionMode()\" class=\"p-2 rounded-lg hover:bg-gray-700\" title=\"Pan/Select Mode\" aria-label=\"Toggle pan/select mode\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 5 3 v 12 M 5 15 l -2 2 M 5 15 l 2 2 M 5 3 l 3 3\"></path></svg></button></div></template><!-- Separator --><div class=\"w-px h-6 bg-gray-600 mx-1\"></div><!-- Progress display --><div id=\"progress-display\" x-text=\"progressText\" @click=\"cycleProgressMode()\" :title=\"progressTooltip()\" class=\"text-sm min-w-[4rem] text-center cursor-pointer\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+233
-29
@@ -395,6 +395,11 @@ document.addEventListener("alpine:init", () => {
|
|||||||
tocItem: FoliateTocItem | null;
|
tocItem: FoliateTocItem | null;
|
||||||
section: { current: number; total: number };
|
section: { current: number; total: number };
|
||||||
} | null,
|
} | null,
|
||||||
|
chapterBoundaries: [] as {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
startPage: number;
|
||||||
|
}[],
|
||||||
async initReader(config: {
|
async initReader(config: {
|
||||||
mediaItemId: string;
|
mediaItemId: string;
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
@@ -440,11 +445,12 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.renderer.addEventListener("zoom", () => {
|
this.renderer.addEventListener("zoom", () => {
|
||||||
this.zoomPercent = this.renderer.zoomPercent;
|
this.zoomPercent = this.renderer.zoomPercent;
|
||||||
});
|
});
|
||||||
|
this.computeFixedLayoutChapterBoundaries();
|
||||||
} else {
|
} else {
|
||||||
this.renderer.setStyles?.(this.buildCSS());
|
this.renderer.setStyles?.(this.buildCSS());
|
||||||
}
|
}
|
||||||
this.view.addEventListener("load", (e: any) => {
|
this.view.addEventListener("load", (e: any) => {
|
||||||
const { doc } = e.detail;
|
const { doc, index } = e.detail;
|
||||||
const link = doc.createElement("link");
|
const link = doc.createElement("link");
|
||||||
link.rel = "stylesheet";
|
link.rel = "stylesheet";
|
||||||
link.href = "/static/reader-fonts.css";
|
link.href = "/static/reader-fonts.css";
|
||||||
@@ -452,6 +458,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
doc.addEventListener("keydown", (ev: KeyboardEvent) =>
|
doc.addEventListener("keydown", (ev: KeyboardEvent) =>
|
||||||
this.handleKeydown(ev),
|
this.handleKeydown(ev),
|
||||||
);
|
);
|
||||||
|
if (!this.isFixedLayout) {
|
||||||
|
this.computeChapterPageBoundaries(doc);
|
||||||
|
doc.fonts.ready.then(() => this.computeChapterPageBoundaries(doc));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
this.view.addEventListener("relocate", (e: any) => {
|
this.view.addEventListener("relocate", (e: any) => {
|
||||||
const { fraction, location, pageItem, cfi, tocItem, section } =
|
const { fraction, location, pageItem, cfi, tocItem, section } =
|
||||||
@@ -521,6 +531,36 @@ document.addEventListener("alpine:init", () => {
|
|||||||
async saveProgress(fraction: number, location: any, cfi: string) {
|
async saveProgress(fraction: number, location: any, cfi: string) {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token || !this.mediaItemId) return;
|
if (!token || !this.mediaItemId) return;
|
||||||
|
|
||||||
|
const body: Record<string, any> = {
|
||||||
|
percentage: fraction,
|
||||||
|
current_page: this.isFixedLayout
|
||||||
|
? (this.renderer?.index ?? 0) + 1
|
||||||
|
: location?.current ?? 0,
|
||||||
|
total_pages: this.isFixedLayout
|
||||||
|
? this.book?.sections?.filter((s: any) => s.linear !== "no")
|
||||||
|
?.length ?? 0
|
||||||
|
: location?.total ?? 0,
|
||||||
|
epubcfi: cfi || "",
|
||||||
|
reading_mode: this.readingMode || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tocItem = this.lastRelocateDetail?.tocItem;
|
||||||
|
if (tocItem) {
|
||||||
|
if (tocItem.label) {
|
||||||
|
const boundaryIdx = this.chapterBoundaries.findIndex(
|
||||||
|
(b: any) => b.tocItem === tocItem,
|
||||||
|
);
|
||||||
|
if (boundaryIdx !== -1) {
|
||||||
|
body.chapter = boundaryIdx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isFixedLayout && this.renderer?.zoomPercent) {
|
||||||
|
body.zoom_level = this.renderer.zoomPercent / 100;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/media-items/${this.mediaItemId}/progress`, {
|
await fetch(`/api/media-items/${this.mediaItemId}/progress`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -528,12 +568,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
percentage: fraction,
|
|
||||||
current_page: location?.current ?? 0,
|
|
||||||
total_pages: location?.total ?? 0,
|
|
||||||
epubcfi: cfi || "",
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// silent fail — progress save is non-critical
|
// silent fail — progress save is non-critical
|
||||||
@@ -723,23 +758,87 @@ document.addEventListener("alpine:init", () => {
|
|||||||
case "percentage":
|
case "percentage":
|
||||||
return percent;
|
return percent;
|
||||||
case "chapter": {
|
case "chapter": {
|
||||||
if (!section || !this.sectionFractionsArr.length) return percent;
|
if (this.isFixedLayout && tocItem && this.chapterBoundaries.length > 0) {
|
||||||
const idx = section.current;
|
const totalSections = this.book?.sections?.filter(
|
||||||
const startFrac = this.sectionFractionsArr[idx] ?? 0;
|
(s: any) => s.linear !== "no",
|
||||||
const endFrac = this.sectionFractionsArr[idx + 1] ?? 1;
|
)?.length ?? 0;
|
||||||
const sectionFrac = endFrac - startFrac;
|
const currentPage = this.renderer?.index ?? 0;
|
||||||
if (sectionFrac <= 0) return percent;
|
const boundaryIdx = this.chapterBoundaries.findIndex(
|
||||||
const totalInSec = Math.max(
|
(b) => b.id === tocItem.id,
|
||||||
1,
|
);
|
||||||
Math.round(sectionFrac * location.total),
|
if (boundaryIdx !== -1 && totalSections > 0) {
|
||||||
);
|
const chapterStart =
|
||||||
const currentInSec = Math.max(
|
this.chapterBoundaries[boundaryIdx].startPage;
|
||||||
1,
|
const nextBoundary =
|
||||||
Math.round((fraction - startFrac) * location.total),
|
this.chapterBoundaries[boundaryIdx + 1];
|
||||||
);
|
const chapterEnd = nextBoundary
|
||||||
const clamped = Math.min(currentInSec, totalInSec);
|
? nextBoundary.startPage
|
||||||
const label = tocItem?.label ? `${tocItem.label} · ` : "";
|
: totalSections;
|
||||||
return `${label}${clamped} / ${totalInSec}`;
|
const chapterPages = Math.max(1, chapterEnd - chapterStart);
|
||||||
|
const currentInChapter = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(currentPage - chapterStart + 1, chapterPages),
|
||||||
|
);
|
||||||
|
const label = tocItem.label
|
||||||
|
? `${tocItem.label} · `
|
||||||
|
: "";
|
||||||
|
return `${label}${currentInChapter} / ${chapterPages}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tocItem && this.chapterBoundaries.length > 0) {
|
||||||
|
const boundaryIdx = this.chapterBoundaries.findIndex(
|
||||||
|
(b) => b.id === tocItem.id,
|
||||||
|
);
|
||||||
|
if (boundaryIdx !== -1) {
|
||||||
|
const currentPage = this.renderer?.page ?? 1;
|
||||||
|
const chapterStart =
|
||||||
|
this.chapterBoundaries[boundaryIdx].startPage;
|
||||||
|
const nextBoundary =
|
||||||
|
this.chapterBoundaries[boundaryIdx + 1];
|
||||||
|
const totalPages = this.renderer?.pages ?? 0;
|
||||||
|
const chapterEnd = nextBoundary
|
||||||
|
? nextBoundary.startPage
|
||||||
|
: totalPages > 2
|
||||||
|
? totalPages - 1
|
||||||
|
: chapterStart + 1;
|
||||||
|
const chapterPages = Math.max(1, chapterEnd - chapterStart);
|
||||||
|
const currentInChapter = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(currentPage - chapterStart + 1, chapterPages),
|
||||||
|
);
|
||||||
|
const label = tocItem.label
|
||||||
|
? `${tocItem.label} · `
|
||||||
|
: "";
|
||||||
|
return `${label}${currentInChapter} / ${chapterPages}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (section && this.sectionFractionsArr.length > 1) {
|
||||||
|
const pageInfo = this.getRenderedPageInfo();
|
||||||
|
const pageBase = pageInfo?.total ?? location.total;
|
||||||
|
const idx = section.current;
|
||||||
|
const startFrac = this.sectionFractionsArr[idx] ?? 0;
|
||||||
|
const endFrac = this.sectionFractionsArr[idx + 1] ?? 1;
|
||||||
|
const sectionFrac = endFrac - startFrac;
|
||||||
|
if (sectionFrac > 0 && pageBase > 0) {
|
||||||
|
const totalInSec = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(sectionFrac * pageBase),
|
||||||
|
);
|
||||||
|
const currentInSec = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round((fraction - startFrac) * pageBase),
|
||||||
|
);
|
||||||
|
const clamped = Math.min(currentInSec, totalInSec);
|
||||||
|
const label = tocItem?.label
|
||||||
|
? `${tocItem.label} · `
|
||||||
|
: "";
|
||||||
|
return `${label}${clamped} / ${totalInSec}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (location.total > 0) {
|
||||||
|
return `${percent} · ${location.current} / ${location.total}`;
|
||||||
|
}
|
||||||
|
return percent;
|
||||||
}
|
}
|
||||||
case "time-left": {
|
case "time-left": {
|
||||||
if (this.readingSpeedPpm > 0 && location.total > 0) {
|
if (this.readingSpeedPpm > 0 && location.total > 0) {
|
||||||
@@ -752,13 +851,23 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
return `${percent} · ~${mins} min left`;
|
return `${percent} · ~${mins} min left`;
|
||||||
}
|
}
|
||||||
return percent;
|
return `${percent} · ~-- min left`;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
const loc = pageItem
|
if (this.isFixedLayout) {
|
||||||
? `Page ${pageItem.label}`
|
const pageInfo = this.getRenderedPageInfo();
|
||||||
: `Loc ${location.current}`;
|
if (pageInfo) {
|
||||||
return `${percent} · ${loc}`;
|
return `${pageInfo.current} / ${pageInfo.total}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pageItem) {
|
||||||
|
return `${percent} · Page ${pageItem.label}`;
|
||||||
|
}
|
||||||
|
const pageInfoReflow = this.getRenderedPageInfo();
|
||||||
|
if (pageInfoReflow) {
|
||||||
|
return `${percent} · ${pageInfoReflow.current + 1} / ${pageInfoReflow.total}`;
|
||||||
|
}
|
||||||
|
return `${percent} · ${location.current} / ${location.total}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -802,6 +911,101 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
applyProgressMode() {
|
applyProgressMode() {
|
||||||
saveSettings({ progress_mode: this.progressMode as any });
|
saveSettings({ progress_mode: this.progressMode as any });
|
||||||
|
if (this.lastRelocateDetail) {
|
||||||
|
const { fraction, location, pageItem, tocItem, section } =
|
||||||
|
this.lastRelocateDetail;
|
||||||
|
this.progressText = this.formatProgress(
|
||||||
|
fraction,
|
||||||
|
location,
|
||||||
|
pageItem,
|
||||||
|
tocItem,
|
||||||
|
section,
|
||||||
|
);
|
||||||
|
const slider = document.getElementById(
|
||||||
|
"progress-slider",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
if (slider) {
|
||||||
|
slider.title = this.progressText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computeFixedLayoutChapterBoundaries() {
|
||||||
|
if (!this.book?.toc || !this.view) return;
|
||||||
|
const flat = this.flattenTOC(this.book.toc);
|
||||||
|
for (const item of flat) {
|
||||||
|
try {
|
||||||
|
const resolved = this.view.resolveNavigation(item.href);
|
||||||
|
if (resolved?.index != null) {
|
||||||
|
this.chapterBoundaries.push({
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
startPage: resolved.index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip unresolvable anchors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.chapterBoundaries.sort(
|
||||||
|
(a, b) => a.startPage - b.startPage,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
computeChapterPageBoundaries(doc: Document) {
|
||||||
|
this.chapterBoundaries = [];
|
||||||
|
if (!this.book?.toc) return;
|
||||||
|
const size = this.renderer?.size;
|
||||||
|
if (!size) return;
|
||||||
|
const flat = this.flattenTOC(this.book.toc);
|
||||||
|
for (const item of flat) {
|
||||||
|
const fragment = item.href?.split("#")[1];
|
||||||
|
if (!fragment) continue;
|
||||||
|
try {
|
||||||
|
const el = this.book.getTOCFragment?.(doc, fragment);
|
||||||
|
if (!el) continue;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const page = Math.floor(rect.left / size) + 1;
|
||||||
|
this.chapterBoundaries.push({
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
startPage: page,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// skip unresolvable anchors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.chapterBoundaries.sort((a, b) => a.startPage - b.startPage);
|
||||||
|
},
|
||||||
|
getRenderedPageInfo(): { total: number; current: number } | null {
|
||||||
|
if (this.isFixedLayout) {
|
||||||
|
const total =
|
||||||
|
this.book?.sections?.filter(
|
||||||
|
(s: any) => s.linear !== "no",
|
||||||
|
)?.length ?? 0;
|
||||||
|
if (total === 0) return null;
|
||||||
|
const index = this.renderer?.index ?? 0;
|
||||||
|
return { total, current: index + 1 };
|
||||||
|
}
|
||||||
|
const pages = this.renderer?.pages;
|
||||||
|
if (pages != null && pages > 2) {
|
||||||
|
return { total: pages - 2, current: (this.renderer.page ?? 1) - 1 };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
progressTooltip(): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
pages: "Pages",
|
||||||
|
chapter: "Chapter",
|
||||||
|
percentage: "Percentage",
|
||||||
|
"time-left": "Time Left",
|
||||||
|
};
|
||||||
|
const label = labels[this.progressMode] ?? "Pages";
|
||||||
|
if (
|
||||||
|
this.progressMode === "chapter" &&
|
||||||
|
this.chapterBoundaries.length === 0
|
||||||
|
) {
|
||||||
|
return `${label} · no chapter data · Click to switch`;
|
||||||
|
}
|
||||||
|
return `${label} · Click to switch`;
|
||||||
},
|
},
|
||||||
handleKeydown(event: KeyboardEvent) {
|
handleKeydown(event: KeyboardEvent) {
|
||||||
const k = event.key;
|
const k = event.key;
|
||||||
|
|||||||
Reference in New Issue
Block a user