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:
2026-04-19 14:27:56 -04:00
parent e2d2dbecab
commit afeb3f5b45
9 changed files with 382 additions and 349 deletions
+177 -42
View File
@@ -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();
});