refactor(reader): rewrite reader module for foliate-js pan/zoom integration
Major rewrite of the web reader to properly interface with
@bookhoard/foliate-js, replacing the abandoned panel-detection
architecture with direct pan and zoom support built into the
foliate-js FixedLayout renderer.
Template (reader.templ):
- Fix critical bug: x-init config was using literal strings
'{ readerData.X }' inside a quoted attribute, which templ
treated as raw text and never interpolated. Values were never
actually passed to JavaScript. Now uses fmt.Sprintf() with
templ's expression attribute syntax ={ }.
- Pass fileUrl from server so foliate-js can open books directly.
- Redesign bottom bar with foliate-js parity: left/right navigation
buttons, progress slider with tick marks, and zoom controls
(zoom out, percentage display, zoom in, magnifier, pan/select
mode toggle for PDFs).
- Remove panel editor button and enablePanelDetection config.
- Add SVG icon styles for consistent reader controls.
Go types (templates/types.go):
- Expand ReaderMetadata with FormatGroup, MangaType,
ReadingDirection, FileURL, and LibraryID fields needed by
the reader frontend.
Router (internal/router/reader.go):
- Populate new ReaderMetadata fields from database values.
- Construct FileURL from library ID and file path for the
/uploads/library-{id}/* file serving route.
Reader JS (reader.ts):
- Full rewrite modeled on foliate-js Reader class, adapted for
Alpine.js. Opens books via view.open(fileUrl), accesses
view.renderer for zoom/pan/navigation, and wires up keyboard
shortcuts (+/-/0 for zoom, arrows for nav, Escape for magnifier).
- Uses view.isFixedLayout instead of importing FixedLayout class,
avoiding a TypeScript module resolution issue with the Vite alias.
Settings manager (settings-manager.ts):
- Remove dependency on deleted ReaderContext event bus.
- Export loadSettings/saveSettings/syncSettings directly as
standalone async functions.
Cleanup:
- Delete reader-context.ts and reader-events.ts (over-engineered
event system replaced by direct function calls).
- Remove panel_zoom_enabled from ReaderSettings type.
This commit is contained in:
+177
-42
@@ -1,57 +1,192 @@
|
||||
import "foliate-js/view.js";
|
||||
import { Alpine } from "../alpine";
|
||||
|
||||
import { loadSettings, saveSettings } from "./settings-manager";
|
||||
const getCSS = ({
|
||||
spacing,
|
||||
justify,
|
||||
hyphenate,
|
||||
}: {
|
||||
spacing: number;
|
||||
justify: boolean;
|
||||
hyphenate: boolean;
|
||||
}) => `
|
||||
@namespace epub "http://www.idpf.org/2007/ops";
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
a:link {
|
||||
color: lightblue;
|
||||
}
|
||||
}
|
||||
p, li, blockquote, dd {
|
||||
line-height: ${spacing};
|
||||
text-align: ${justify ? "justify" : "start"};
|
||||
-webkit-hyphens: ${hyphenate ? "auto" : "manual"};
|
||||
hyphens: ${hyphenate ? "auto" : "manual"};
|
||||
-webkit-hyphenate-limit-before: 3;
|
||||
-webkit-hyphenate-limit-after: 2;
|
||||
-webkit-hyphenate-limit-lines: 2;
|
||||
hanging-punctuation: allow-end last;
|
||||
widows: 2;
|
||||
}
|
||||
[align="left"] { text-align: left; }
|
||||
[align="right"] { text-align: right; }
|
||||
[align="center"] { text-align: center; }
|
||||
[align="justify"] { text-align: justify; }
|
||||
pre {
|
||||
white-space: pre-wrap !important;
|
||||
}
|
||||
aside[epub|type~="endnote"],
|
||||
aside[epub|type~="footnote"],
|
||||
aside[epub|type~="note"],
|
||||
aside[epub|type~="rearnote"] {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("readerShell", () => ({
|
||||
enablePanelDetection: false,
|
||||
libraryType: "",
|
||||
formatGroup: "",
|
||||
mangaType: "",
|
||||
readingDirection: "",
|
||||
panelDetector: null,
|
||||
|
||||
initReader(config: any) {
|
||||
this.enablePanelDetection = config.enablePanelDetection;
|
||||
this.libraryType = config.libraryType;
|
||||
this.formatGroup = config.formatGroup;
|
||||
this.mangaType = config.mangaType;
|
||||
this.readingDirection = config.readingDirection;
|
||||
|
||||
console.log("Reader initialized with:", {
|
||||
panelDetection: this.enablePanelDetection,
|
||||
library: this.libraryType,
|
||||
format: this.formatGroup,
|
||||
view: null as any,
|
||||
renderer: null as any,
|
||||
book: null as any,
|
||||
zoomPercent: 100,
|
||||
isFixedLayout: false,
|
||||
isPDF: false,
|
||||
interactionMode: "select" as string,
|
||||
magnifierEnabled: false,
|
||||
progressText: "",
|
||||
sliderValue: 0,
|
||||
settings: null as ReaderSettings | null,
|
||||
style: {
|
||||
spacing: 1.4,
|
||||
justify: true,
|
||||
hyphenate: true,
|
||||
},
|
||||
async initReader(config: {
|
||||
mediaItemId: string;
|
||||
fileUrl: string;
|
||||
formatGroup: string;
|
||||
readingDirection: string;
|
||||
mangaType: string;
|
||||
}) {
|
||||
this.settings = await loadSettings();
|
||||
this.view = document.getElementById("reader-view") as any;
|
||||
await this.view.open(config.fileUrl);
|
||||
this.renderer = this.view.renderer;
|
||||
this.book = this.view.book;
|
||||
this.isFixedLayout = this.view.isFixedLayout;
|
||||
if (this.isFixedLayout) {
|
||||
this.isPDF = (this.renderer as any).isPDF;
|
||||
this.renderer.addEventListener("zoom", () => {
|
||||
this.zoomPercent = this.renderer.zoomPercent;
|
||||
});
|
||||
} else {
|
||||
this.renderer.setStyles?.(getCSS(this.style));
|
||||
}
|
||||
this.view.addEventListener("load", (e: any) => {
|
||||
const { doc } = e.detail;
|
||||
doc.addEventListener("keydown", (ev: KeyboardEvent) =>
|
||||
this.handleKeydown(ev),
|
||||
);
|
||||
});
|
||||
|
||||
// Only load panel detection if enabled
|
||||
if (this.enablePanelDetection) {
|
||||
this.loadPanelDetection();
|
||||
this.view.addEventListener("relocate", (e: any) => {
|
||||
const { fraction, location, tocItem, pageItem } = e.detail;
|
||||
const percent = new Intl.NumberFormat("en", {
|
||||
style: "percent",
|
||||
}).format(fraction);
|
||||
const loc = pageItem
|
||||
? `Page ${pageItem.label}`
|
||||
: `Loc ${location.current}`;
|
||||
this.progressText = `${percent} · ${loc}`;
|
||||
this.sliderValue = fraction;
|
||||
const slider = document.getElementById(
|
||||
"progress-slider",
|
||||
) as HTMLInputElement;
|
||||
if (slider) {
|
||||
slider.value = fraction;
|
||||
slider.title = `${percent} · ${loc}`;
|
||||
}
|
||||
});
|
||||
const slider = document.getElementById(
|
||||
"progress-slider",
|
||||
) as HTMLInputElement;
|
||||
if (slider && this.book.dir) {
|
||||
slider.dir = this.book.dir;
|
||||
}
|
||||
},
|
||||
|
||||
async loadPanelDetection() {
|
||||
try {
|
||||
// Dynamic import to only load when needed
|
||||
const { PanelDetector } = await import("foliate-js/panel-detection.js");
|
||||
this.panelDetector = new PanelDetector();
|
||||
console.log("Panel detection loaded successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to load panel detection:", error);
|
||||
if (this.view.getSectionFractions) {
|
||||
const tickMarks = document.getElementById("tick-marks");
|
||||
if (tickMarks) {
|
||||
for (const fraction of this.view.getSectionFractions()) {
|
||||
const option = document.createElement("option");
|
||||
option.value = fraction;
|
||||
tickMarks.append(option);
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", (ev: KeyboardEvent) =>
|
||||
this.handleKeydown(ev),
|
||||
);
|
||||
this.renderer.next();
|
||||
},
|
||||
zoomIn() {
|
||||
if (!this.isFixedLayout) return;
|
||||
const newScale = Math.min(10, this.renderer.currentScale * 1.2);
|
||||
this.renderer.setAttribute("zoom", newScale);
|
||||
this.zoomPercent = this.renderer.zoomPercent;
|
||||
},
|
||||
zoomOut() {
|
||||
if (!this.isFixedLayout) return;
|
||||
const newScale = Math.max(0.1, this.renderer.currentScale / 1.2);
|
||||
this.renderer.setAttribute("zoom", newScale);
|
||||
this.zoomPercent = this.renderer.zoomPercent;
|
||||
},
|
||||
resetZoom() {
|
||||
if (!this.isFixedLayout) return;
|
||||
this.renderer.resetZoom();
|
||||
this.renderer.dragOffset = { x: 0, y: 0 };
|
||||
this.zoomPercent = 100;
|
||||
},
|
||||
toggleMagnifier() {
|
||||
if (!this.isFixedLayout) return;
|
||||
this.renderer.toggleMagnifier();
|
||||
this.magnifierEnabled = this.renderer.zoomMagnifierEnabled;
|
||||
},
|
||||
toggleInteractionMode() {
|
||||
if (!this.isFixedLayout) return;
|
||||
const current =
|
||||
this.renderer.getAttribute("interaction-mode") || "select";
|
||||
const next = current === "select" ? "pan" : "select";
|
||||
this.renderer.setAttribute("interaction-mode", next);
|
||||
this.interactionMode = next;
|
||||
},
|
||||
goLeft() {
|
||||
this.view?.goLeft?.();
|
||||
},
|
||||
goRight() {
|
||||
this.view?.goRight?.();
|
||||
},
|
||||
|
||||
nextPage() {
|
||||
const view = document.querySelector("#reader-view");
|
||||
// @ts-ignore - foliate custom element
|
||||
view?.next?.();
|
||||
this.view?.next?.();
|
||||
},
|
||||
|
||||
previousPage() {
|
||||
const view = document.querySelector("#reader-view");
|
||||
// @ts-ignore - foliate custom element
|
||||
view?.prev?.();
|
||||
this.view?.prev?.();
|
||||
},
|
||||
goToFraction(value: string) {
|
||||
this.view?.goToFraction?.(parseFloat(value));
|
||||
},
|
||||
handleKeydown(event: KeyboardEvent) {
|
||||
const k = event.key;
|
||||
if (k === "ArrowLeft" || k === "h") this.goLeft();
|
||||
else if (k === "ArrowRight" || k === "l") this.goRight();
|
||||
else if (k === "+" || k === "=") this.zoomIn();
|
||||
else if (k === "-" || k === "_") this.zoomOut();
|
||||
else if (k === "0") this.resetZoom();
|
||||
else if (k === "Escape") {
|
||||
if (this.isFixedLayout && this.renderer?.zoomMagnifierEnabled) {
|
||||
this.toggleMagnifier();
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Alpine.start();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user