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:
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
-1
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user