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
-82
View File
@@ -1,82 +0,0 @@
import { readerEvents, ReaderEventType } from "./reader-events";
import { UniversalReader } from "../reader-shell";
import { PDFDocumentProxy } from "pdfjs-dist";
interface PDFReader {
type: "pdf";
doc: PDFDocumentProxy;
currentPage: number;
}
interface ComicReader {
type: "comic";
images: Blob[];
currentPage: number;
}
interface MangaReader {
type: "manga";
images: Blob[];
currentPage: number;
readingDirection: "rtl" | "vertical";
}
export type CurrentReader =
| UniversalReader
| PDFReader
| ComicReader
| MangaReader;
export interface ReaderContext {
getState: () => {
currentReader: CurrentReader | null;
readerMetadata: ReaderMetadata | null;
};
setState: (updates: Partial<ReaderState>) => void;
navigation: {
nextPage: () => void;
previousPage: () => void;
goToPage: (page: number) => void;
goToChapter: (chapterIndex: number) => void;
};
render: () => void;
elements: {
readerContent: HTMLElement;
chrome: HTMLElement | null;
progressDisplay: HTMLElement | null;
};
events: {
on: (event: string, handler: Function) => void;
emit: (event: string, data?: any) => void;
};
}
export interface ReaderState {
currentReader: CurrentReader | null;
readerMetadata: ReaderMetadata | null;
}
export function createReaderContext(
getState: () => ReaderState,
setState: (updates: Partial<ReaderState>) => void,
navigation: ReaderContext["navigation"],
render: () => void,
): ReaderContext {
return {
getState,
setState,
navigation,
render,
elements: {
readerContent: document.getElementById("reader-content")!,
chrome: document.getElementById("reader-chrome"),
progressDisplay: document.getElementById("progress-display"),
},
events: {
on: (event: ReaderEventType, handler: Function) =>
readerEvents.on(event, handler),
emit: (event: ReaderEventType, data?: any) =>
readerEvents.emit(event, data),
},
};
}
-42
View File
@@ -1,42 +0,0 @@
type ReaderEventType =
| "readerReady"
| "pageChanged"
| "chapterChanged"
| "zoomChanged"
| "themeChanged"
| "progressUpdated"
| "beforePageChange"
| "afterPageChange"
| "settings:changed";
class EventBus {
private listeners = new Map<ReaderEventType, Function[]>();
on(event: ReaderEventType, handler: Function): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)!.push(handler);
}
off(event: ReaderEventType, handler: Function): void {
const handlers = this.listeners.get(event);
if (handlers) {
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
}
}
emit<T = unknown>(event: ReaderEventType, data?: T): void {
this.listeners.get(event)?.forEach((handler) => handler(data));
}
clear(): void {
this.listeners.clear();
}
}
export const readerEvents = new EventBus();
export type { ReaderEventType };
+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();
});
+4 -62
View File
@@ -1,64 +1,12 @@
// Per-user settings with localStorage fallback
// Feature Registration Pattern implementation
import type { ReaderContext } from "./core/reader-context";
import { apiGet, apiPut } from "../api";
import { getToken } from "../storage";
const LOCALSTORAGE_KEY = "reader_settings_local";
export function init(context: ReaderContext): void {
let currentSettings: ReaderSettings | null = null;
context.events.on("reader:init", async () => {
currentSettings = await loadSettings();
context.events.emit("settings:loaded", currentSettings);
});
context.events.on(
"settings:save",
async (detail: { settings: Partial<ReaderSettings> }) => {
await saveSettings(detail.settings);
currentSettings = await loadSettings();
context.events.emit("settings:changed", currentSettings);
},
);
context.events.on(
"settings:get",
(detail: { key?: keyof ReaderSettings }) => {
if (currentSettings) {
const value = detail.key
? currentSettings[detail.key]
: currentSettings;
context.events.emit("settings:current", { value });
}
},
);
context.events.on(
"settings:set",
async (detail: { key: keyof ReaderSettings; value: any }) => {
await saveSettings({ [detail.key]: detail.value });
currentSettings = await loadSettings();
context.events.emit("settings:changed", currentSettings);
},
);
context.events.on("settings:sync", async () => {
await syncSettings();
currentSettings = await loadSettings();
context.events.emit("settings:synced", currentSettings);
});
}
export async function loadSettings(): Promise<ReaderSettings> {
const token = getToken();
if (!token) {
const local = localStorage.getItem(LOCALSTORAGE_KEY);
return local ? JSON.parse(local) : getDefaultSettings();
}
try {
const response = await apiGet("/readers/settings");
const settings = await response.json();
@@ -69,17 +17,16 @@ export async function loadSettings(): Promise<ReaderSettings> {
return local ? JSON.parse(local) : getDefaultSettings();
}
}
async function saveSettings(settings: Partial<ReaderSettings>): Promise<void> {
export async function saveSettings(
settings: Partial<ReaderSettings>,
): Promise<void> {
const token = getToken();
const current = await loadSettings();
const updated = { ...current, ...settings };
if (!token) {
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
return;
}
try {
await apiPut("/readers/settings", settings);
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
@@ -87,14 +34,11 @@ async function saveSettings(settings: Partial<ReaderSettings>): Promise<void> {
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
}
}
async function syncSettings(): Promise<void> {
export async function syncSettings(): Promise<void> {
const local = localStorage.getItem(LOCALSTORAGE_KEY);
if (!local) return;
const settings = JSON.parse(local);
const token = getToken();
if (token) {
try {
await apiPut("/readers/settings", settings);
@@ -103,7 +47,6 @@ async function syncSettings(): Promise<void> {
}
}
}
export function getDefaultSettings(): ReaderSettings {
return {
chrome_behavior: "auto-hide",
@@ -113,7 +56,6 @@ export function getDefaultSettings(): ReaderSettings {
reading_font: "literata",
tap_zone_size: 30,
auto_scroll: false,
panel_zoom_enabled: true,
font_size: 16,
line_height: 1.6,
margin_width: 20,
-1
View File
@@ -206,7 +206,6 @@ interface ReaderSettings {
tap_zone_size: number;
auto_scroll: boolean;
panel_zoom_enabled: boolean;
double_page_spread: boolean;
reading_direction: "ltr" | "rtl" | "vertical";