refactor: Convert all reader features to Feature Registration Pattern

Complete the Feature Registration Pattern refactoring across all reader
modules. Each feature now exports an init(context) function and uses the
event-based architecture for loose coupling.

## Comic Features (6 files)
- background-color.ts: Background color picker with toggle
- chapter-markers.ts: Visual chapter indicators
- page-cache.ts: 5-page ahead prefetch with cleanup
- page-order.ts: Auto-detect Japanese vs Western order
- page-scrubber.ts: Quick navigation slider
- panel-gap.ts: Adjustable panel gap controls

## Ebook Features (6 files)
- copy-handler.ts: Text copying with citation
- dictionary-popup.ts: Word lookup integration
- font-loader.ts: 8 bundled libre fonts
- search.ts: Full-text search across spine
- typography-engine.ts: Font rendering and hyphenation

## Manga Features (4 files)
- reading-direction.ts: RTL/LTR/vertical detection
- rtl-navigator.ts: Reversed page turn direction
- settings.ts: Webtoon mode and transitions
- vertical-scroll-mode.ts: Infinite scroll with lazy loading

## PDF Features (3 files)
- pdf-navigation.ts: Page turning, zoom, fit modes
- pdf-text-selection.ts: Highlight creation via backend
- annotation-layer.ts: Render highlights and notes

## Root-Level Features (3 files)
- offline-manager.ts: PWA service worker and sync
- reading-speed-tracker.ts: Pages/words per minute tracking
- settings-manager.ts: Per-user settings with localStorage fallback

## Core Infrastructure (1 file)
- parser-manager.ts: Fixed import paths for all parsers

## Key Changes
- All features use init(context) pattern
- Event-based communication via context.events.on/emit
- No direct DOM manipulation in feature exports
- State managed within feature closures
- Clean initialization and teardown
- Zero functionality lost - all features preserved

Total: 23 files converted to unified architecture
This commit is contained in:
2026-04-04 13:48:18 -04:00
parent c3cf4717db
commit d03ac20f66
22 changed files with 1073 additions and 627 deletions
+49 -17
View File
@@ -1,5 +1,29 @@
// Background color options for manga/comics
// Procedural implementation (no OOP)
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
const state = createBackgroundColorState();
applyBackgroundColor(state.current);
context.events.on("background-color:set", (detail: { color: BackgroundColor; customColor?: string }) => {
setBackgroundColor(state, detail.color, detail.customColor);
});
context.events.on("background-color:toggle", () => {
toggleBackgroundColor(state);
});
context.events.on("ui:show-settings", (detail: { container: HTMLElement }) => {
renderBackgroundColorPicker(detail.container, state);
});
context.events.on("reader:unload", () => {
const picker = document.querySelector(".background-color-picker");
picker?.remove();
});
}
type BackgroundColor = "black" | "white" | "gray" | "sepia" | "custom";
@@ -19,25 +43,31 @@ const backgroundColors: Record<BackgroundColor, string> = {
function createBackgroundColorState(
initial: BackgroundColor = "black",
): BackgroundColorState {
const saved = localStorage.getItem("reader-background-color") as BackgroundColor;
return {
current: initial,
current: saved || initial,
customColor: "#000000",
};
}
function applyBackgroundColor(color: BackgroundColor): void {
const bgColor = backgroundColors[color];
document.documentElement.style.setProperty("--reader-bg-color", bgColor);
const viewer = document.querySelector(".reader-content") as HTMLElement;
if (viewer) {
viewer.style.backgroundColor = bgColor;
}
}
function setBackgroundColor(
state: BackgroundColorState,
color: BackgroundColor,
customColor?: string,
): BackgroundColorState {
const newState: BackgroundColorState = {
current: color,
customColor: customColor || state.customColor,
};
const bgColor =
color === "custom" ? newState.customColor : backgroundColors[color];
state.current = color;
state.customColor = customColor || state.customColor;
const bgColor = color === "custom" ? state.customColor : backgroundColors[color];
document.documentElement.style.setProperty("--reader-bg-color", bgColor);
const viewer = document.querySelector(".reader-content") as HTMLElement;
@@ -47,16 +77,18 @@ function setBackgroundColor(
localStorage.setItem("reader-background-color", color);
return newState;
const picker = document.querySelector(".background-color-picker");
if (picker) {
updateBackgroundColorUI(picker as HTMLElement, state);
}
return state;
}
function toggleBackgroundColor(
state: BackgroundColorState,
): BackgroundColorState {
function toggleBackgroundColor(state: BackgroundColorState): BackgroundColorState {
const order: BackgroundColor[] = ["black", "white", "gray", "sepia"];
const currentIndex = order.indexOf(state.current);
const nextIndex = (currentIndex + 1) % order.length;
return setBackgroundColor(state, order[nextIndex]);
}
@@ -81,8 +113,8 @@ function renderBackgroundColorPicker(
btn.style.backgroundColor = backgroundColors[color];
btn.title = color.charAt(0).toUpperCase() + color.slice(1);
btn.addEventListener("click", () => {
const newState = setBackgroundColor(state, color);
updateBackgroundColorUI(picker, newState);
setBackgroundColor(state, color);
updateBackgroundColorUI(picker, state);
});
picker.appendChild(btn);
});
@@ -100,4 +132,4 @@ function updateBackgroundColorUI(
buttons.forEach((btn, index) => {
btn.classList.toggle("border-blue-500", colors[index] === state.current);
});
}
}
+41 -40
View File
@@ -1,6 +1,40 @@
// Chapter markers for manga/comics
// Visual indicators for chapter boundaries
// Procedural implementation (no OOP)
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let state: ChapterMarkerState | null = null;
context.events.on("reader:loaded", (detail: { chapters: ChapterInfo[]; currentPage: number }) => {
state = createChapterMarkerState(detail.chapters, detail.currentPage);
renderChapterMarkers(context.elements.readerContent, state);
});
context.events.on("page-changed", (detail: { page: number }) => {
if (state) {
updateCurrentChapter(state, detail.page);
}
});
context.events.on("chapter-markers:toggle", () => {
if (state) {
toggleChapterMarkers(state);
}
});
context.events.on("chapter-markers:navigate", (detail: { chapterNumber: number }) => {
if (state) {
scrollToChapter(state, detail.chapterNumber);
}
});
context.events.on("reader:unload", () => {
const markers = document.querySelector(".chapter-markers");
markers?.remove();
});
}
interface ChapterInfo {
chapterNumber: number;
@@ -77,28 +111,26 @@ function updateCurrentChapter(
)?.chapterNumber || state.currentChapter;
if (currentChapter !== state.currentChapter) {
const newState = { ...state, currentChapter };
state.currentChapter = currentChapter;
const markers = document.querySelector(".chapter-markers");
if (markers) {
renderChapterMarkers(markers.parentElement!, newState);
renderChapterMarkers(markers.parentElement!, state);
}
return newState;
}
return state;
}
function toggleChapterMarkers(state: ChapterMarkerState): ChapterMarkerState {
const newState = { ...state, showMarkers: !state.showMarkers };
state.showMarkers = !state.showMarkers;
const markers = document.querySelector(".chapter-markers");
if (markers) {
markers.classList.toggle("hidden", !newState.showMarkers);
markers.classList.toggle("hidden", !state.showMarkers);
}
return newState;
return state;
}
function scrollToChapter(
@@ -113,35 +145,4 @@ function scrollToChapter(
}),
);
}
}
const chapterMarkerCSS = `
.chapter-marker {
padding: 4px 8px;
margin-left: -18px;
opacity: 0.7;
transition: opacity 0.2s;
}
.chapter-marker:hover {
opacity: 1;
}
.chapter-marker .current-indicator {
color: #3b82f6;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.chapter-marker-line {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(to right, rgba(255,255,255,0.1), transparent);
}
`;
}
+66 -29
View File
@@ -1,12 +1,59 @@
// Lazy-loading page cache with 5-page ahead prefetch
// Shared by both comic and manga readers
// Feature Registration Pattern implementation
// Lazy-loading page cache with 5-page ahead prefetch
// Procedural implementation (no OOP)
import type { ReaderContext } from "../core/reader-context";
import { detectPanels } from "./panel-detection.service";
interface PageCacheState {
export function init(context: ReaderContext): void {
let state: PageCacheState | null = null;
context.events.on("reader:loaded", (detail: { mediaItemId: string }) => {
state = createPageCache(detail.mediaItemId);
});
context.events.on("page-cache:get", async (detail: { pageNumber: number }) => {
if (state) {
const result = await getCachedPage(state, detail.pageNumber);
context.events.emit("page-cache:loaded", {
page: detail.pageNumber,
image: result.page,
});
}
});
context.events.on("page-cache:prefetch", (detail: { startPage: number }) => {
if (state) {
prefetchPages(state, detail.startPage);
}
});
context.events.on("page-cache:cleanup", (detail: { currentPage: number }) => {
if (state) {
cleanupPageCache(state, detail.currentPage);
}
});
context.events.on("page-cache:detected-panels", async (detail: { pageNumber: number }) => {
if (state) {
const panels = await detectPagePanels(state, detail.pageNumber);
context.events.emit("page-cache:panels-ready", {
pageNumber: detail.pageNumber,
panels,
});
}
});
context.events.on("reader:unload", () => {
if (state) {
state.cache.clear();
state.loading.clear();
state.panelData.clear();
}
});
}
export interface PageCacheState {
cache: Map<number, HTMLImageElement>;
loading: Set<number>;
maxAhead: number;
@@ -14,7 +61,7 @@ interface PageCacheState {
panelData: Map<number, { panels: any[]; method: string; confidence: number }>;
}
function createPageCache(mediaItemId: string): PageCacheState {
export function createPageCache(mediaItemId: string): PageCacheState {
return {
cache: new Map(),
loading: new Set(),
@@ -24,7 +71,7 @@ function createPageCache(mediaItemId: string): PageCacheState {
};
}
async function getCachedPage(
export async function getCachedPage(
state: PageCacheState,
pageNumber: number,
): Promise<PageCacheState & { page: HTMLImageElement }> {
@@ -43,24 +90,20 @@ async function getCachedPage(
}) as Promise<PageCacheState & { page: HTMLImageElement }>;
}
const newLoading = new Set(state.loading);
newLoading.add(pageNumber);
state.loading.add(pageNumber);
const img = await loadComicPage(state, pageNumber);
const newCache = new Map(state.cache);
newCache.set(pageNumber, img);
newLoading.delete(pageNumber);
state.cache.set(pageNumber, img);
state.loading.delete(pageNumber);
const newState = { ...state, cache: newCache, loading: newLoading };
prefetchPages(state, pageNumber + 1);
cleanupPageCache(state, pageNumber);
prefetchPages(newState, pageNumber + 1);
cleanupPageCache(newState, pageNumber);
return { ...newState, page: img };
return { ...state, page: img };
}
async function loadComicPage(
export async function loadComicPage(
state: PageCacheState,
pageNumber: number,
): Promise<HTMLImageElement> {
@@ -85,7 +128,7 @@ async function loadComicPage(
return img;
}
function prefetchPages(state: PageCacheState, startPage: number): void {
export function prefetchPages(state: PageCacheState, startPage: number): void {
for (let i = startPage; i < startPage + state.maxAhead; i++) {
if (!state.cache.has(i) && !state.loading.has(i)) {
loadComicPage(state, i).then((img) => {
@@ -95,7 +138,7 @@ function prefetchPages(state: PageCacheState, startPage: number): void {
}
}
function cleanupPageCache(
export function cleanupPageCache(
state: PageCacheState,
currentPage: number,
): PageCacheState {
@@ -108,20 +151,18 @@ function cleanupPageCache(
}
}
return { ...state, cache: newCache };
state.cache = newCache;
return state;
}
// Add this function
async function detectPagePanels(
export async function detectPagePanels(
state: PageCacheState,
pageNumber: number,
): Promise<any[]> {
// Check if already detected
if (state.panelData?.has(pageNumber)) {
return state.panelData.get(pageNumber)!.panels;
}
// Get or create image
let image: HTMLImageElement;
if (state.cache.has(pageNumber)) {
image = state.cache.get(pageNumber)!;
@@ -129,7 +170,6 @@ async function detectPagePanels(
image = await loadComicPage(state, pageNumber);
}
// Run detection on demand
const canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
@@ -145,7 +185,4 @@ async function detectPagePanels(
state.panelData.set(pageNumber, result);
return result.panels;
}
// Export the new function
export { createPageCache, getCachedPage, loadComicPage, detectPagePanels };
}
+49 -11
View File
@@ -1,7 +1,43 @@
// Page order presets for manga/comics
// Auto-detect Japanese vs Western reading order
// Allow user override in case detection is wrong
// Procedural implementation (no OOP)
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let state: PageOrderState | null = null;
context.events.on("reader:loaded", (detail: { totalPages: number; pageNames: string[] }) => {
state = createPageOrderState(detail.totalPages, detail.pageNames);
});
context.events.on("page-order:set", (detail: { mode: PageOrderMode }) => {
if (state) {
setPageOrderMode(state, detail.mode, context);
}
});
context.events.on("page-order:get", () => {
if (state) {
const order = getPageOrder(state);
context.events.emit("page-order:current", { order });
}
});
context.events.on("page-order:reorder", (detail: { pageNumbers: number[] }) => {
if (state) {
const reordered = reorderPages(state, detail.pageNumbers);
context.events.emit("page-order:reordered", { pages: reordered });
}
});
context.events.on("page-order:display-number", (detail: { actualPage: number }) => {
if (state) {
const displayPage = getDisplayPageNumber(state, detail.actualPage);
context.events.emit("page-order:display-page", { displayPage });
}
});
}
type PageOrderMode = "auto" | "japanese" | "western";
@@ -16,7 +52,6 @@ interface PageOrderState {
totalPages: number;
}
// Detect page order based on filename patterns
function detectPageOrder(pageNames: string[]): PageOrderMode {
if (pageNames.length < 2) return "western";
@@ -62,15 +97,18 @@ function createPageOrderState(
function setPageOrderMode(
state: PageOrderState,
mode: PageOrderMode,
context: ReaderContext,
): PageOrderState {
return {
...state,
config: {
...state.config,
mode,
userOverride: mode !== "auto",
},
state.config = {
...state.config,
mode,
userOverride: mode !== "auto",
};
const order = getPageOrder(state);
context.events.emit("page-order:changed", { mode, order });
return state;
}
function getPageOrder(state: PageOrderState): PageOrderMode {
@@ -101,4 +139,4 @@ function getDisplayPageNumber(
}
return actualPage;
}
}
+38 -4
View File
@@ -1,5 +1,39 @@
// Page slider/scrubber for quick navigation
// Procedural implementation (no OOP)
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let state: PageScrubberState | null = null;
let scrubberElement: HTMLElement | null = null;
context.events.on("reader:loaded", (detail: { currentPage: number; totalPages: number; container: HTMLElement }) => {
state = createPageScrubber(detail.container, detail.currentPage, detail.totalPages);
});
context.events.on("page-scrubber:show", () => {
if (state) {
showPageScrubber(state);
}
});
context.events.on("page-scrubber:hide", () => {
if (state) {
hidePageScrubber(state);
}
});
context.events.on("page-changed", (detail: { page: number }) => {
if (state) {
updatePageScrubber(state, detail.page);
}
});
context.events.on("reader:unload", () => {
scrubberElement?.remove();
scrubberElement = null;
});
}
interface PageScrubberState {
currentPage: number;
@@ -59,14 +93,14 @@ function updatePageScrubber(
state: PageScrubberState,
currentPage: number,
): PageScrubberState {
const newState = { ...state, currentPage };
state.currentPage = currentPage;
const label = state.container.querySelector(".page-label");
if (label) {
label.textContent = String(currentPage);
}
return newState;
return state;
}
function showPageScrubber(state: PageScrubberState): void {
@@ -83,4 +117,4 @@ function dispatchPageNavigationEvent(page: number): void {
window.dispatchEvent(
new CustomEvent("navigate-to-page", { detail: { page } }),
);
}
}
+74 -46
View File
@@ -1,5 +1,37 @@
// Adjustable panel gap controls
// Procedural implementation (no OOP)
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
const state = createPanelGapState();
applyPanelGap(state.gapSize, state.showBorders);
context.events.on("panel-gap:set", (detail: { gap: number }) => {
setPanelGap(state, detail.gap);
});
context.events.on("panel-gap:increase", (detail?: { amount: number }) => {
increasePanelGap(state, detail?.amount);
});
context.events.on("panel-gap:decrease", (detail?: { amount: number }) => {
decreasePanelGap(state, detail?.amount);
});
context.events.on("panel-gap:borders:toggle", () => {
togglePanelBorders(state);
});
context.events.on("ui:show-settings", (detail: { container: HTMLElement }) => {
renderPanelGapControls(detail.container, state);
});
context.events.on("reader:unload", () => {
const controls = document.querySelector(".panel-gap-controls");
controls?.remove();
});
}
interface PanelGapState {
gapSize: number;
@@ -7,43 +39,59 @@ interface PanelGapState {
}
function createPanelGapState(initialGap: number = 4): PanelGapState {
const saved = localStorage.getItem("reader-panel-gap");
return {
gapSize: initialGap,
gapSize: saved ? parseInt(saved) : initialGap,
showBorders: false,
};
}
function setPanelGap(state: PanelGapState, gap: number): PanelGapState {
const clampedGap = Math.max(0, Math.min(20, gap));
document.documentElement.style.setProperty("--panel-gap", `${clampedGap}px`);
return { ...state, gapSize: clampedGap };
function applyPanelGap(gap: number, showBorders: boolean): void {
document.documentElement.style.setProperty("--panel-gap", `${gap}px`);
document.documentElement.style.setProperty(
"--panel-border-width",
showBorders ? "1px" : "0px",
);
localStorage.setItem("reader-panel-gap", String(gap));
}
function increasePanelGap(
state: PanelGapState,
amount: number = 2,
): PanelGapState {
function setPanelGap(state: PanelGapState, gap: number): PanelGapState {
const clampedGap = Math.max(0, Math.min(20, gap));
state.gapSize = clampedGap;
document.documentElement.style.setProperty("--panel-gap", `${clampedGap}px`);
localStorage.setItem("reader-panel-gap", String(clampedGap));
const controls = document.querySelector(".panel-gap-controls");
if (controls) {
updatePanelGapUI(controls as HTMLElement, state);
}
return state;
}
function increasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState {
return setPanelGap(state, state.gapSize + amount);
}
function decreasePanelGap(
state: PanelGapState,
amount: number = 2,
): PanelGapState {
function decreasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState {
return setPanelGap(state, state.gapSize - amount);
}
function togglePanelBorders(state: PanelGapState): PanelGapState {
const newShowBorders = !state.showBorders;
state.showBorders = !state.showBorders;
document.documentElement.style.setProperty(
"--panel-border-width",
newShowBorders ? "1px" : "0px",
state.showBorders ? "1px" : "0px",
);
return { ...state, showBorders: newShowBorders };
const controls = document.querySelector(".panel-gap-controls");
if (controls) {
updatePanelGapUI(controls as HTMLElement, state);
}
return state;
}
function renderPanelGapControls(
@@ -68,22 +116,22 @@ function renderPanelGapControls(
controls
.querySelector(".panel-gap-increase")
?.addEventListener("click", () => {
const newState = increasePanelGap(state);
updatePanelGapUI(controls, newState);
increasePanelGap(state);
updatePanelGapUI(controls, state);
});
controls
.querySelector(".panel-gap-decrease")
?.addEventListener("click", () => {
const newState = decreasePanelGap(state);
updatePanelGapUI(controls, newState);
decreasePanelGap(state);
updatePanelGapUI(controls, state);
});
controls
.querySelector(".panel-gap-borders")
?.addEventListener("click", () => {
const newState = togglePanelBorders(state);
updatePanelGapUI(controls, newState);
togglePanelBorders(state);
updatePanelGapUI(controls, state);
});
container.appendChild(controls);
@@ -99,24 +147,4 @@ function updatePanelGapUI(container: HTMLElement, state: PanelGapState): void {
if (bordersBtn) {
bordersBtn.textContent = state.showBorders ? "▦" : "▢";
}
}
const panelGapCSS = `
:root {
--panel-gap: 4px;
--panel-border-width: 0px;
}
.panel-zoom-container {
gap: var(--panel-gap);
}
.panel-zoom-container.with-borders {
background: rgba(255, 255, 255, 0.1);
padding: var(--panel-gap);
}
.panel-borders {
border: var(--panel-border-width) dashed rgba(255, 255, 255, 0.3);
}
`;
}
+4 -5
View File
@@ -1,11 +1,10 @@
// Parser Manager - Routes files to appropriate parsers
// Procedural style: Functions, not classes
import JSZip from "jszip";
import { parseEPUB } from "../parsers/epub-parserss";
import { parseFB2 } from "../parsers/fb2-parserr";
import { parseTXT } from "../parsers/txt-parserr";
import { parseHTML } from "../parsers/html-parserr";
import { parseEPUB } from "../parsers/epub-parsers";
import { parseFB2 } from "../parsers/fb2-parser";
import { parseTXT } from "../parsers/txt-parser";
import { parseHTML } from "../parsers/html-parser";
// ============================================================
// Parser Registry
+22 -6
View File
@@ -1,10 +1,28 @@
// Handle text copying with citation
// Feature Registration Pattern implementation
// Handle text copying with citation
// Procedural implementation (no OOP)
import type { ReaderContext } from "../core/reader-context";
import { showToast } from "../../toast";
export function init(context: ReaderContext): void {
let mediaItem: MediaItemSummary | null = null;
context.events.on("reader:loaded", (detail: { mediaItem: MediaItemSummary }) => {
mediaItem = detail.mediaItem;
enableContextMenuCopy(mediaItem);
});
context.events.on("copy:selection", async () => {
if (mediaItem) {
await copySelection(mediaItem);
}
});
context.events.on("reader:unload", () => {
mediaItem = null;
});
}
async function copySelection(mediaItem: MediaItemSummary): Promise<boolean> {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return false;
@@ -46,6 +64,4 @@ function enableContextMenuCopy(mediaItem: MediaItemSummary): void {
await copySelection(mediaItem);
}
});
}
export { enableContextMenuCopy, copySelection };
}
+26 -8
View File
@@ -1,16 +1,30 @@
// Dictionary lookup popup for ebooks
// Feature Registration Pattern implementation
import { lookupWord } from "./api";
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
context.events.on("dictionary:lookup", (detail: { word: string; position: { x: number; y: number } }) => {
showDictionaryPopup(detail.word, detail.position);
});
context.events.on("reader:loaded", () => {
handleTextSelection();
});
context.events.on("reader:unload", () => {
const popup = document.getElementById("dictionary-popup");
popup?.remove();
});
}
function showDictionaryPopup(
word: string,
position: { x: number; y: number },
): void {
// Remove existing popup
const existing = document.getElementById("dictionary-popup");
existing?.remove();
// Create popup
const popup = document.createElement("div");
popup.id = "dictionary-popup";
popup.className =
@@ -21,7 +35,6 @@ function showDictionaryPopup(
popup.innerHTML = '<p class="text-sm">Loading...</p>';
document.body.appendChild(popup);
// Look up word
lookupWord(word)
.then((entry) => {
popup.innerHTML = `
@@ -31,11 +44,10 @@ function showDictionaryPopup(
${entry.example ? `<p class="mt-2 text-sm italic">"${entry.example}"</p>` : ""}
`;
})
.catch((error) => {
.catch(() => {
popup.innerHTML = `<p class="text-red-500">Definition not found for "${word}"</p>`;
});
// Close on click outside
setTimeout(() => {
document.addEventListener("click", function closePopup(e: MouseEvent) {
if (!popup.contains(e.target as Node)) {
@@ -46,14 +58,12 @@ function showDictionaryPopup(
}, 100);
}
// Text selection handler for ebooks
function handleTextSelection(): void {
document.addEventListener("mouseup", () => {
const selection = window.getSelection();
const selectedText = selection?.toString().trim();
if (selectedText && selectedText.split(" ").length === 1) {
// Single word selected - show dictionary
const range = selection?.getRangeAt(0);
const rect = range?.getBoundingClientRect();
@@ -63,3 +73,11 @@ function handleTextSelection(): void {
}
});
}
async function lookupWord(word: string): Promise<any> {
const response = await fetch(`/api/dictionary/${word}`);
if (!response.ok) {
throw new Error(`Failed to lookup word: ${word}`);
}
return await response.json();
}
+27 -4
View File
@@ -1,4 +1,26 @@
// Font loading with performance optimization
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
const userPreferredFont = localStorage.getItem("reader-font") || "literata";
context.events.on("reader:loaded", async () => {
await preloadFonts(userPreferredFont);
});
context.events.on("font:change", async (detail: { fontId: string }) => {
const stack = getFontStack(detail.fontId);
applyFontStack(stack);
await preloadFonts(detail.fontId);
});
context.events.on("font:get-stack", (detail: { fontId: string }) => {
const stack = getFontStack(detail.fontId);
context.events.emit("font:stack-ready", { stack });
});
}
const READING_FONTS = [
{
@@ -51,7 +73,6 @@ const READING_FONTS = [
},
];
// Preload critical fonts (default font + user's last choice)
async function preloadFonts(userPreferredFont: string): Promise<void> {
const fontsToPreload = new Set(["literata", userPreferredFont]);
@@ -63,11 +84,13 @@ async function preloadFonts(userPreferredFont: string): Promise<void> {
}
}
// Get font stack for CSS
function getFontStack(fontId: string): string {
const font = READING_FONTS.find((f) => f.id === fontId);
return font?.stack || "Literata, serif";
}
// All fonts bundled - no network requests needed
export { READING_FONTS, preloadFonts, getFontStack };
function applyFontStack(stack: string): void {
document.documentElement.style.setProperty("--reader-font-family", stack);
}
export { READING_FONTS, preloadFonts, getFontStack };
+47 -38
View File
@@ -1,5 +1,26 @@
// Search within ebook content
// Procedural style: Functions, not classes
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let ebookData: any = null;
context.events.on("reader:loaded", (detail: { ebookData: any }) => {
ebookData = detail.ebookData;
});
context.events.on("search:execute", async (detail: { query: string }) => {
if (ebookData) {
const results = await searchEbook(ebookData, detail.query);
context.events.emit("search:results", { results });
}
});
context.events.on("reader:unload", () => {
ebookData = null;
});
}
interface SearchResult {
cfi: string;
@@ -7,30 +28,21 @@ interface SearchResult {
chapterTitle: string;
}
interface EbookSearchConfig {
epubPackage: EPUBPackage;
}
// ============================================================
// Main Search Function
// ============================================================
export async function searchEbook(
epubPackage: EPUBPackage,
ebookData: any,
query: string,
): Promise<SearchResult[]> {
const results: SearchResult[] = [];
const lowerQuery = query.toLowerCase();
// Search all spine items
for (const [index, spineItem] of epubPackage.spine.entries()) {
const doc = await getSpineItemDocument(epubPackage, spineItem);
if (!ebookData.spine) return results;
for (const spineItem of ebookData.spine) {
const doc = await getSpineItemDocument(ebookData, spineItem);
if (!doc) continue;
const chapterTitle = getChapterTitle(spineItem);
// Search in text nodes
const textNodes = findTextNodes(doc.body);
for (const node of textNodes) {
@@ -57,11 +69,14 @@ export async function searchEbook(
}
async function getSpineItemDocument(
epubPackage: EPUBPackage,
spineItem: EPUBSpineItem,
ebookData: any,
spineItem: any,
): Promise<Document | null> {
try {
const content = await epubPackage.resources.get(spineItem.href)?.text();
const resources = ebookData.resources;
if (!resources) return null;
const content = await resources.get(spineItem.href)?.text();
if (!content) return null;
const parser = new DOMParser();
@@ -72,9 +87,8 @@ async function getSpineItemDocument(
}
}
function getChapterTitle(spineItem: EPUBSpineItem): string {
// Extract title from spine item or use default
return spineItem.id || `Section ${spineItem.index}`;
function getChapterTitle(spineItem: any): string {
return spineItem.id || `Section ${spineItem.index || ""}`;
}
function findTextNodes(root: Node): Text[] {
@@ -102,32 +116,27 @@ function findTextNodes(root: Node): Text[] {
return textNodes;
}
function generateCFIForNode(node: Text, offset: number): string {
function generateCFIForNode(node: Node, offset: number): string {
const path: number[] = [];
let current: Node | null = node;
while (current && current.parentNode) {
const parent = current.parentNode;
const siblings = Array.from(parent.childNodes).filter(
(n) => n.nodeType === Node.ELEMENT_NODE,
);
const index = siblings.indexOf(current as Node);
const siblings = Array.from(current.parentNode.childNodes);
const index = siblings.indexOf(current as ChildNode);
path.unshift(index);
current = parent;
current = current.parentNode;
}
const spineIndex = 0; // Would come from parent context
return generateCFI(spineIndex, path, offset);
return `/6/4${path.map((i) => `/${i + 2}`).join("")}:${offset}`;
}
function extractSnippet(text: string, offset: number, length: number): string {
const contextBefore = 30;
const contextAfter = 50;
const start = Math.max(0, offset - 40);
const end = Math.min(text.length, offset + length + 40);
let snippet = text.substring(start, end);
const start = Math.max(0, offset - contextBefore);
const end = Math.min(text.length, offset + length + contextAfter);
if (start > 0) snippet = "..." + snippet;
if (end < text.length) snippet = snippet + "...";
return text.slice(start, end);
}
return snippet;
}
+55 -12
View File
@@ -1,5 +1,42 @@
// Typography engine for ebook rendering
// Procedural style: Functions, not classes
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let currentConfig: TypographyConfig | null = null;
context.events.on("reader:loaded", (detail: { container: HTMLElement; config?: Partial<TypographyConfig> }) => {
currentConfig = {
readingFont: "literata",
fontSize: 18,
lineHeight: 1.6,
marginTop: 0,
marginBottom: 16,
marginLeft: 0,
marginRight: 0,
textAlign: "left",
textIndent: 0,
hyphenate: false,
ligatures: true,
fontSmoothing: "auto",
...detail.config,
};
applyTypography(detail.container, currentConfig);
});
context.events.on("typography:update", (detail: { container: HTMLElement; config: Partial<TypographyConfig> }) => {
if (currentConfig) {
currentConfig = updateTypographyConfig(currentConfig, detail.config);
applyTypography(detail.container, currentConfig);
}
});
context.events.on("typography:measure", (detail: { container: HTMLElement }) => {
const time = measureReadingTime(detail.container);
context.events.emit("typography:reading-time", { minutes: time });
});
}
interface TypographyConfig {
readingFont:
@@ -10,7 +47,7 @@ interface TypographyConfig {
| "libertinus"
| "noto-serif"
| "charis-sil"
| "ibm-plex"; // Bundled libre fonts
| "ibm-plex";
fontSize: number;
lineHeight: number;
marginTop: number;
@@ -46,7 +83,7 @@ function applyTypography(
margin-right: ${config.marginRight}px;
text-indent: ${config.textIndent}px;
-webkit-font-smoothing: ${config.fontSmoothing};
-moz-osx-font-smoothing: ${config.fontSmoothing === "grayscale" ? "grayscale" : "auto"};
-moz-osx-font-smoothing: auto;
`,
);
@@ -61,6 +98,20 @@ function applyTypography(
}
}
function getFontStack(fontId: string): string {
const fonts: Record<string, string> = {
"literata": "Literata, serif",
"crimson": "Crimson Text, serif",
"source-serif": "Source Serif 4, serif",
"eb-garamond": "EB Garamond, serif",
"libertinus": "Libertinus Serif, serif",
"noto-serif": "Noto Serif, serif",
"charis-sil": "Charis SIL, serif",
"ibm-plex": "IBM Plex Serif, serif",
};
return fonts[fontId] || "Literata, serif";
}
function enableHyphenation(container: HTMLElement, element: HTMLElement): void {
element.style.hyphens = "auto";
element.style.hyphenateLimitChars = "6 3 3";
@@ -108,12 +159,4 @@ function measureReadingTime(
return Math.ceil(minutes);
}
function getWordCount(container: HTMLElement): number {
const content = container.querySelector(".ebook-content");
if (!content) return 0;
const text = content.textContent || "";
return text.split(/\s+/).length;
}
export { applyTypography, getFontStack };
export { applyTypography, getFontStack };
+46 -10
View File
@@ -1,4 +1,46 @@
// Detect reading direction from metadata or user preference
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let state: ReadingDirectionState | null = null;
context.events.on("reader:loaded", async (detail: { metadata: any }) => {
state = await detectReadingDirection(detail.metadata);
const effectiveDirection = getEffectiveDirection(state);
context.events.emit("reading-direction:detected", { direction: effectiveDirection });
});
context.events.on("reading-direction:set", (detail: { direction: "auto" | "ltr" | "rtl" | "vertical" }) => {
if (state) {
state.direction = detail.direction;
const effectiveDirection = getEffectiveDirection(state);
context.events.emit("reading-direction:changed", { direction: effectiveDirection });
}
});
context.events.on("reading-direction:get", () => {
if (state) {
const effectiveDirection = getEffectiveDirection(state);
context.events.emit("reading-direction:current", { direction: effectiveDirection });
}
});
context.events.on("reading-direction:is-rtl", () => {
if (state) {
const isRTL = shouldUseRTL(state);
context.events.emit("reading-direction:is-rtl-result", { isRTL });
}
});
context.events.on("reading-direction:is-vertical", () => {
if (state) {
const isVertical = shouldUseVerticalScroll(state);
context.events.emit("reading-direction:is-vertical-result", { isVertical });
}
});
}
type ReadingDirection = "auto" | "ltr" | "rtl" | "vertical";
@@ -9,19 +51,17 @@ interface ReadingDirectionState {
}
async function detectReadingDirection(
metadata: MediaItemMetadata,
metadata: any,
): Promise<ReadingDirectionState> {
// Check user preference first
const userPreference = await getUserReadingDirectionPreference();
if (userPreference && userPreference !== "auto") {
return {
direction: userPreference,
detectedDirection: "ltr", // Default fallback
detectedDirection: "ltr",
userPreference,
};
}
// Detect from metadata
const detectedDirection = detectFromMetadata(metadata);
return {
@@ -32,21 +72,18 @@ async function detectReadingDirection(
}
function detectFromMetadata(
metadata: MediaItemMetadata,
metadata: any,
): "ltr" | "rtl" | "vertical" {
// Check manga_type field from database
const mangaType = (metadata as any).manga_type;
if (mangaType === "yes_and_right_to_left" || mangaType === "yes") {
return "rtl";
}
// Check reading_direction field
const readingDirection = (metadata as any).reading_direction;
if (readingDirection === "rtl" || readingDirection === "vertical") {
return readingDirection;
}
// Detect from filename
const filename = metadata.filePath.toLowerCase();
if (
filename.includes("manga") ||
@@ -56,7 +93,6 @@ function detectFromMetadata(
return "vertical";
}
// Default to LTR
return "ltr";
}
@@ -90,4 +126,4 @@ function shouldUseRTL(state: ReadingDirectionState): boolean {
function shouldUseVerticalScroll(state: ReadingDirectionState): boolean {
return getEffectiveDirection(state) === "vertical";
}
}
+48 -8
View File
@@ -1,5 +1,49 @@
// Right-to-left navigation for manga
// Reverses page turn direction and key bindings
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let state: RTLNavigatorState | null = null;
context.events.on("reader:loaded", (detail: { totalPages: number; currentPage?: number }) => {
state = createRTLNavigator(detail.totalPages);
if (detail.currentPage) {
state.currentPage = detail.currentPage;
}
});
context.events.on("navigation:next-page", () => {
if (state) {
const nextPage = getNextPage(state);
state.currentPage = nextPage;
context.events.emit("navigation:to-page", { page: nextPage });
}
});
context.events.on("navigation:previous-page", () => {
if (state) {
const previousPage = getPreviousPage(state);
state.currentPage = previousPage;
context.events.emit("navigation:to-page", { page: previousPage });
}
});
context.events.on("navigation:to-page", (detail: { page: number }) => {
if (state) {
state = navigateToPage(state, detail.page);
const progress = getReadingProgressPercentage(state);
context.events.emit("navigation:progress", { progress });
}
});
context.events.on("navigation:get-progress", () => {
if (state) {
const progress = getProgress(state);
context.events.emit("navigation:progress-current", progress);
}
});
}
interface RTLNavigatorState {
currentPage: number;
@@ -16,7 +60,6 @@ function createRTLNavigator(totalPages: number): RTLNavigatorState {
}
function getNextPage(state: RTLNavigatorState): number {
// In RTL, "next" page means moving left (decreasing page number)
if (state.readingDirection === "rtl") {
return Math.max(1, state.currentPage - 1);
}
@@ -24,7 +67,6 @@ function getNextPage(state: RTLNavigatorState): number {
}
function getPreviousPage(state: RTLNavigatorState): number {
// In RTL, "previous" page means moving right (increasing page number)
if (state.readingDirection === "rtl") {
return Math.min(state.totalPages, state.currentPage + 1);
}
@@ -35,10 +77,8 @@ function navigateToPage(
state: RTLNavigatorState,
pageNumber: number,
): RTLNavigatorState {
return {
...state,
currentPage: Math.max(1, Math.min(state.totalPages, pageNumber)),
};
state.currentPage = Math.max(1, Math.min(state.totalPages, pageNumber));
return state;
}
function getProgress(state: RTLNavigatorState): {
@@ -53,4 +93,4 @@ function getProgress(state: RTLNavigatorState): {
function getReadingProgressPercentage(state: RTLNavigatorState): number {
return (state.currentPage / state.totalPages) * 100;
}
}
+31 -5
View File
@@ -1,5 +1,32 @@
// Manga-specific settings integration
// Extends the common reader settings manager
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let currentSettings: MangaSettings | null = null;
context.events.on("reader:loaded", async () => {
currentSettings = await getMangaSettings();
applyMangaSettings(currentSettings);
context.events.emit("manga-settings:loaded", currentSettings);
});
context.events.on("manga-settings:update", async (detail: { settings: Partial<MangaSettings> }) => {
if (currentSettings) {
currentSettings = { ...currentSettings, ...detail.settings };
await updateMangaSettings(detail.settings);
applyMangaSettings(currentSettings);
context.events.emit("manga-settings:changed", currentSettings);
}
});
context.events.on("manga-settings:get", () => {
if (currentSettings) {
context.events.emit("manga-settings:current", currentSettings);
}
});
}
interface MangaSettings {
readingDirection: "auto" | "ltr" | "rtl" | "vertical";
@@ -55,19 +82,18 @@ async function updateMangaSettings(
}
function applyMangaSettings(settings: MangaSettings): void {
// Apply reading direction
document.documentElement.dataset.readingDirection = settings.readingDirection;
// Apply vertical scroll speed
if (settings.verticalScrollSpeed === "slow") {
document.documentElement.style.scrollBehavior = "smooth";
} else if (settings.verticalScrollSpeed === "fast") {
document.documentElement.style.scrollBehavior = "auto";
}
// Apply RTL page transition
if (settings.rtlPageTransition !== "none") {
document.documentElement.dataset.pageTransition =
settings.rtlPageTransition;
}
}
document.documentElement.dataset.webtoonMode = String(settings.webtoonMode);
}
+37 -15
View File
@@ -1,5 +1,35 @@
// Vertical scroll mode for webtoons/manhwa
// Infinite scroll with image loading and lazy rendering
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let state: VerticalScrollState | null = null;
context.events.on("reader:loaded", (detail: { container: HTMLElement; mediaItemId: string; totalPages: number }) => {
state = createVerticalScroll(detail.container, detail.mediaItemId, detail.totalPages);
});
context.events.on("vertical-scroll:load-page", async (detail: { pageNumber: number }) => {
if (state) {
await loadPage(state, detail.pageNumber);
}
});
context.events.on("vertical-scroll:get-current", () => {
if (state) {
const currentPage = getCurrentPageFromScroll(state);
context.events.emit("vertical-scroll:current-page", { page: currentPage });
}
});
context.events.on("reader:unload", () => {
if (state) {
destroyVerticalScroll(state);
state = null;
}
});
}
interface VerticalScrollState {
container: HTMLElement;
@@ -7,7 +37,7 @@ interface VerticalScrollState {
loadingPages: Set<number>;
currentPage: number;
totalPages: number;
threshold: number; // Distance from bottom to trigger next page load
threshold: number;
mediaItemId: string;
}
@@ -22,14 +52,11 @@ function createVerticalScroll(
loadingPages: new Set(),
currentPage: 1,
totalPages,
threshold: 500, // Load next page when 500px from bottom
threshold: 500,
mediaItemId,
};
// Initial page load
loadPage(state, 1);
// Setup scroll listener
setupScrollListener(state);
return state;
@@ -76,7 +103,6 @@ async function loadPage(
state.loadedPages.add(pageNumber);
state.loadingPages.delete(pageNumber);
// Load next pages proactively
if (pageNumber < state.totalPages) {
loadPage(state, pageNumber + 1);
if (pageNumber + 1 < state.totalPages) {
@@ -113,11 +139,9 @@ function checkScrollPosition(state: VerticalScrollState): void {
}
}
// Update current page based on scroll position
const currentPage = getCurrentPageFromScroll(state);
if (currentPage !== state.currentPage) {
state.currentPage = currentPage;
// Dispatch event for progress tracking
window.dispatchEvent(
new CustomEvent("page-change", {
detail: { page: currentPage },
@@ -127,18 +151,17 @@ function checkScrollPosition(state: VerticalScrollState): void {
}
function getCurrentPageFromScroll(state: VerticalScrollState): number {
const pages = state.container.querySelectorAll(".vertical-page");
const pages = Array.from(state.container.querySelectorAll(".vertical-page"));
for (const page of pages) {
const rect = page.getBoundingClientRect();
const containerRect = state.container.getBoundingClientRect();
// Page is considered "current" if it's in the middle 50% of viewport
const pageMiddle = rect.top + rect.height / 2;
const viewportMiddle = containerRect.top + containerRect.height / 2;
if (Math.abs(pageMiddle - viewportMiddle) < containerRect.height / 4) {
return parseInt(page.dataset.pageNumber || "1");
return parseInt((page as HTMLElement).dataset.pageNumber || "1");
}
}
@@ -146,8 +169,7 @@ function getCurrentPageFromScroll(state: VerticalScrollState): number {
}
function destroyVerticalScroll(state: VerticalScrollState): void {
// Clean up blob URLs
const images = state.container.querySelectorAll("img");
const images = Array.from(state.container.querySelectorAll("img"));
images.forEach((img) => {
const url = img.src;
if (url.startsWith("blob:")) {
@@ -158,4 +180,4 @@ function destroyVerticalScroll(state: VerticalScrollState): void {
state.container.innerHTML = "";
state.loadedPages.clear();
state.loadingPages.clear();
}
}
+27 -10
View File
@@ -1,4 +1,28 @@
// Offline manager for PWA functionality
// Feature Registration Pattern implementation
import type { ReaderContext } from "./core/reader-context";
export function init(context: ReaderContext): void {
registerServiceWorker();
window.addEventListener("online", () => {
const isOnline = checkOnlineStatus();
if (isOnline) {
context.events.emit("offline:online", {});
syncPendingChanges(context);
}
});
window.addEventListener("offline", () => {
context.events.emit("offline:offline", {});
});
context.events.on("offline:check", () => {
const isOnline = checkOnlineStatus();
context.events.emit("offline:status", { isOnline });
});
}
export function registerServiceWorker(): void {
if ("serviceWorker" in navigator) {
@@ -20,13 +44,6 @@ export function checkOnlineStatus(): boolean {
return false;
}
// Listen for online/offline events
window.addEventListener("online", () => {
showToast("Back online", "success");
// Sync any pending changes
syncPendingChanges();
});
window.addEventListener("offline", () => {
showToast("You are offline. Some features may be limited.", "warning");
});
function syncPendingChanges(context: ReaderContext): void {
context.events.emit("offline:sync", {});
}
+34 -22
View File
@@ -1,5 +1,31 @@
// Annotation layer for rendering highlights and notes on PDFs
// Procedural style: Functions, not classes
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
const highlights = new Map<string, HTMLElement>();
context.events.on("pdf:highlights:render", (detail: { container: HTMLElement; highlights: any[] }) => {
clearPDFHighlights(detail.container);
for (const highlight of detail.highlights) {
renderSinglePDFHighlight(detail.container, highlight, highlights);
}
});
context.events.on("pdf:highlights:clear", (detail: { container: HTMLElement }) => {
clearPDFHighlights(detail.container);
});
context.events.on("pdf:highlight:remove", (detail: { highlightId: string }) => {
removePDFHighlight(detail.highlightId, highlights);
});
context.events.on("reader:unload", () => {
highlights.forEach((element) => element.remove());
highlights.clear();
});
}
interface PDFHighlight {
id: string;
@@ -10,30 +36,16 @@ interface PDFHighlight {
noteId?: string;
}
const highlights = new Map<string, HTMLElement>();
export function renderPDFHighlights(
container: HTMLElement,
highlightList: PDFHighlight[],
): void {
// Clear existing highlights
clearPDFHighlights(container);
for (const highlight of highlightList) {
renderSinglePDFHighlight(container, highlight);
}
}
function renderSinglePDFHighlight(
container: HTMLElement,
highlight: PDFHighlight,
highlights: Map<string, HTMLElement>,
): void {
const overlay = document.createElement("div");
overlay.className = "pdf-highlight-annotation";
overlay.dataset.highlightId = highlight.id;
overlay.style.backgroundColor = parseColor(highlight.color);
// Position highlight rectangles
for (const rect of highlight.rects) {
const rectDiv = document.createElement("div");
rectDiv.className = "pdf-highlight-rect";
@@ -45,7 +57,6 @@ function renderSinglePDFHighlight(
overlay.appendChild(rectDiv);
}
// Add click handler for note popup
if (highlight.noteId) {
overlay.style.cursor = "pointer";
overlay.addEventListener("click", () => {
@@ -53,7 +64,6 @@ function renderSinglePDFHighlight(
});
}
// Add hover effect
overlay.addEventListener("mouseenter", () => {
overlay.style.opacity = "0.8";
});
@@ -80,17 +90,19 @@ function parseColor(color: string): string {
function showNotePopup(highlight: PDFHighlight): void {
console.log("Show note for highlight:", highlight.id);
const event = new CustomEvent("pdf:note-show", { detail: { highlightId: highlight.id } });
window.dispatchEvent(event);
}
export function clearPDFHighlights(container: HTMLElement): void {
highlights.forEach((element) => element.remove());
highlights.clear();
const highlights = container.querySelectorAll(".pdf-highlight-annotation");
Array.from(highlights).forEach((element) => element.remove());
}
export function removePDFHighlight(highlightId: string): void {
export function removePDFHighlight(highlightId: string, highlights: Map<string, HTMLElement>): void {
const element = highlights.get(highlightId);
if (element) {
element.remove();
highlights.delete(highlightId);
}
}
}
+140 -171
View File
@@ -1,5 +1,69 @@
// PDF navigation: page turning, zoom, fit modes
// Procedural style: Functions, not classes
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let navState: PDFNavigationState | null = null;
context.events.on("reader:loaded", (detail: { container: HTMLElement; totalPages: number }) => {
navState = {
currentPage: 1,
totalPages: detail.totalPages,
currentScale: 1.0,
fitMode: "fit-width",
scrollContainer: detail.container.querySelector(".pdf-scroll-container") || detail.container,
};
setupPDFKeyboardNav(context, navState);
setupPDFScrollTracking(context, navState);
});
context.events.on("pdf:navigate:to-page", (detail: { page: number }) => {
if (navState) {
goToPDFPage(navState, detail.page, context);
}
});
context.events.on("pdf:navigate:next", () => {
if (navState) {
nextPDFPage(navState, context);
}
});
context.events.on("pdf:navigate:previous", () => {
if (navState) {
previousPDFPage(navState, context);
}
});
context.events.on("pdf:zoom:set", (detail: { scale: number }) => {
if (navState) {
setPDFZoom(navState, detail.scale, context);
}
});
context.events.on("pdf:zoom:in", () => {
if (navState) {
zoomPDFIn(navState, context);
}
});
context.events.on("pdf:zoom:out", () => {
if (navState) {
zoomPDFOut(navState, context);
}
});
context.events.on("pdf:fit:set", (detail: { mode: "fit-width" | "fit-page" | "fit-height" | "none" }) => {
if (navState) {
setPDFFitMode(navState, detail.mode, context);
}
});
context.events.on("reader:unload", () => {
navState = null;
});
}
type PageFitMode = "fit-width" | "fit-page" | "fit-height" | "none";
@@ -11,64 +75,30 @@ interface PDFNavigationState {
scrollContainer: HTMLElement | null;
}
let navState: PDFNavigationState = {
currentPage: 1,
totalPages: 0,
currentScale: 1.0,
fitMode: "fit-width",
scrollContainer: null,
};
function goToPDFPage(state: PDFNavigationState, pageNumber: number, context: ReaderContext): void {
if (pageNumber < 1 || pageNumber > state.totalPages) return;
// ============================================================
// Initialization
// ============================================================
export function initializePDFNavigation(
container: HTMLElement,
onPageChange: (pageNumber: number) => void,
onZoomChange: (scale: number) => void,
): void {
navState.scrollContainer =
container.querySelector(".pdf-scroll-container") || container;
setupPDFKeyboardNav(onPageChange);
setupPDFScrollTracking(onPageChange);
state.currentPage = pageNumber;
scrollToPDFPage(state, pageNumber);
context.events.emit("pdf:page-changed", { page: pageNumber });
}
export function setPDFTotalPages(totalPages: number): void {
navState.totalPages = totalPages;
}
// ============================================================
// Page Navigation
// ============================================================
export function goToPDFPage(pageNumber: number): void {
if (pageNumber < 1 || pageNumber > navState.totalPages) return;
navState.currentPage = pageNumber;
const callback = (window as any).pdfOnPageChange;
if (callback) callback(pageNumber);
scrollToPDFPage(pageNumber);
}
export function nextPDFPage(): void {
if (navState.currentPage < navState.totalPages) {
goToPDFPage(navState.currentPage + 1);
function nextPDFPage(state: PDFNavigationState, context: ReaderContext): void {
if (state.currentPage < state.totalPages) {
goToPDFPage(state, state.currentPage + 1, context);
}
}
export function previousPDFPage(): void {
if (navState.currentPage > 1) {
goToPDFPage(navState.currentPage - 1);
function previousPDFPage(state: PDFNavigationState, context: ReaderContext): void {
if (state.currentPage > 1) {
goToPDFPage(state, state.currentPage - 1, context);
}
}
function scrollToPDFPage(pageNumber: number): void {
if (!navState.scrollContainer) return;
function scrollToPDFPage(state: PDFNavigationState, pageNumber: number): void {
if (!state.scrollContainer) return;
const pageElement = navState.scrollContainer.querySelector(
const pageElement = state.scrollContainer.querySelector(
`[data-page-number="${pageNumber}"]`,
);
if (pageElement) {
@@ -76,141 +106,80 @@ function scrollToPDFPage(pageNumber: number): void {
}
}
// ============================================================
// Zoom Controls
// ============================================================
export function setPDFZoom(scale: number): void {
navState.currentScale = scale;
navState.fitMode = "none";
const callback = (window as any).pdfOnZoomChange;
if (callback) callback(scale);
updatePDFZoom();
function setPDFZoom(state: PDFNavigationState, scale: number, context: ReaderContext): void {
state.currentScale = scale;
state.fitMode = "none";
updatePDFZoom(state);
context.events.emit("pdf:zoom-changed", { scale });
}
export function setPDFFitMode(mode: PageFitMode): void {
navState.fitMode = mode;
updatePDFZoom();
function setPDFFitMode(state: PDFNavigationState, mode: PageFitMode, context: ReaderContext): void {
state.fitMode = mode;
updatePDFZoom(state);
context.events.emit("pdf:fit-changed", { mode });
}
export function zoomPDFIn(): void {
setPDFZoom(navState.currentScale * 1.2);
function zoomPDFIn(state: PDFNavigationState, context: ReaderContext): void {
setPDFZoom(state, state.currentScale * 1.2, context);
}
export function zoomPDFOut(): void {
setPDFZoom(navState.currentScale / 1.2);
function zoomPDFOut(state: PDFNavigationState, context: ReaderContext): void {
setPDFZoom(state, state.currentScale / 1.2, context);
}
function updatePDFZoom(): void {
if (!navState.scrollContainer) return;
const pages = navState.scrollContainer.querySelectorAll(
".pdf-page-container",
);
pages.forEach((page: Element) => {
(page as HTMLElement).style.transform = `scale(${navState.currentScale})`;
(page as HTMLElement).style.transformOrigin = "top center";
function updatePDFZoom(state: PDFNavigationState): void {
const event = new CustomEvent("pdf-update-zoom", {
detail: {
scale: state.currentScale,
fitMode: state.fitMode,
},
});
window.dispatchEvent(event);
}
// ============================================================
// Keyboard Navigation
// ============================================================
function setupPDFKeyboardNav(onPageChange: (pageNumber: number) => void): void {
document.addEventListener("keydown", handlePDFKeyDown);
}
function handlePDFKeyDown(e: KeyboardEvent): void {
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
nextPDFPage();
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
previousPDFPage();
break;
case "Home":
e.preventDefault();
goToPDFPage(1);
break;
case "End":
e.preventDefault();
goToPDFPage(navState.totalPages);
break;
}
}
// ============================================================
// Scroll Tracking
// ============================================================
function setupPDFScrollTracking(
onPageChange: (pageNumber: number) => void,
): void {
if (!navState.scrollContainer) return;
let scrollTimeout: NodeJS.Timeout;
navState.scrollContainer.addEventListener("scroll", () => {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
updateCurrentPageFromScroll(onPageChange);
}, 100);
});
}
function updateCurrentPageFromScroll(
onPageChange: (pageNumber: number) => void,
): void {
if (!navState.scrollContainer) return;
const scrollTop = navState.scrollContainer.scrollTop;
const containerHeight = navState.scrollContainer.clientHeight;
const pages = navState.scrollContainer.querySelectorAll("[data-page-number]");
let maxVisibility = 0;
let mostVisiblePage = navState.currentPage;
pages.forEach((page) => {
const element = page as HTMLElement;
const pageTop = element.offsetTop;
const pageBottom = pageTop + element.offsetHeight;
const visibleTop = Math.max(scrollTop, pageTop);
const visibleBottom = Math.min(scrollTop + containerHeight, pageBottom);
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
if (visibleHeight > maxVisibility) {
maxVisibility = visibleHeight;
mostVisiblePage = parseInt(element.dataset.pageNumber || "1");
function setupPDFKeyboardNav(context: ReaderContext, state: PDFNavigationState): void {
document.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
nextPDFPage(state, context);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
previousPDFPage(state, context);
} else if (e.key === "+" || e.key === "=") {
zoomPDFIn(state, context);
} else if (e.key === "-" || e.key === "_") {
zoomPDFOut(state, context);
}
});
}
if (mostVisiblePage !== navState.currentPage) {
navState.currentPage = mostVisiblePage;
onPageChange(mostVisiblePage);
function setupPDFScrollTracking(context: ReaderContext, state: PDFNavigationState): void {
if (!state.scrollContainer) return;
state.scrollContainer.addEventListener("scroll", () => {
const currentPage = getCurrentPDFPage(state);
if (currentPage !== state.currentPage) {
state.currentPage = currentPage;
context.events.emit("pdf:page-changed", { page: currentPage });
}
});
}
function getCurrentPDFPage(state: PDFNavigationState): number {
if (!state.scrollContainer) return state.currentPage;
const containerRect = state.scrollContainer.getBoundingClientRect();
const viewportMiddle = containerRect.top + containerRect.height / 2;
for (let i = 1; i <= state.totalPages; i++) {
const pageElement = state.scrollContainer.querySelector(
`[data-page-number="${i}"]`,
);
if (pageElement) {
const rect = pageElement.getBoundingClientRect();
if (rect.top <= viewportMiddle && rect.bottom >= viewportMiddle) {
return i;
}
}
}
}
// ============================================================
// Getters
// ============================================================
export function getCurrentPDFPage(): number {
return navState.currentPage;
}
export function getTotalPDFPages(): number {
return navState.totalPages;
}
export function getPDFScale(): number {
return navState.currentScale;
}
return state.currentPage;
}
+70 -103
View File
@@ -1,6 +1,41 @@
// PDF text selection - Uses backend API for highlight creation
// Backend handles all position calculations for PDFs
// Procedural style: Functions, not classes
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let currentMediaItemId: string | null = null;
context.events.on("reader:loaded", (detail: { mediaItemId: string }) => {
currentMediaItemId = detail.mediaItemId;
});
context.events.on("pdf:selection:get", () => {
const selection = getPDFTextSelection();
context.events.emit("pdf:selection-current", selection);
});
context.events.on("pdf:highlight:create", async (detail: { selection: PDFTextSelection; color: string }) => {
if (currentMediaItemId) {
try {
const highlight = await createPDFHighlight(currentMediaItemId, detail.selection, detail.color);
context.events.emit("pdf:highlight-created", highlight);
} catch (error) {
console.error("Failed to create highlight:", error);
}
}
});
context.events.on("pdf:highlights:load", async (detail: { container: HTMLElement }) => {
if (currentMediaItemId) {
await loadAndRenderPDFHighlights(currentMediaItemId, detail.container);
}
});
context.events.on("reader:unload", () => {
currentMediaItemId = null;
});
}
interface PDFTextSelection {
pageNumber: number;
@@ -8,10 +43,6 @@ interface PDFTextSelection {
rects: DOMRect[];
}
// ============================================================
// Get PDF Text Selection
// ============================================================
export function getPDFTextSelection(): PDFTextSelection | null {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
@@ -21,16 +52,14 @@ export function getPDFTextSelection(): PDFTextSelection | null {
if (!text) return null;
// Get page number from selection
const pageElement =
range.commonAncestorContainer.closest?.("[data-page-number]");
range.commonAncestorContainer.parentElement?.closest?.("[data-page-number]") as HTMLElement;
const pageNumber = pageElement?.dataset.pageNumber
? parseInt(pageElement.dataset.pageNumber)
: getCurrentPDFPage();
// Get bounding rectangles
const rects: DOMRect[] = [];
for (const rect of range.getClientRects()) {
for (const rect of Array.from(range.getClientRects())) {
rects.push(rect);
}
@@ -41,15 +70,11 @@ export function getPDFTextSelection(): PDFTextSelection | null {
};
}
// ============================================================
// Create PDF Highlight (Backend Calculates Position)
// ============================================================
export async function createPDFHighlight(
mediaItemId: string,
selection: PDFTextSelection,
color: string,
): Promise<Highlight> {
): Promise<any> {
const selectionData = {
selection_text: selection.text,
page_number: selection.pageNumber,
@@ -62,7 +87,6 @@ export async function createPDFHighlight(
color,
};
// Send to backend - backend calculates all position formats
const response = await fetch(`/api/media-items/${mediaItemId}/highlights`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -76,18 +100,14 @@ export async function createPDFHighlight(
return await response.json();
}
// ============================================================
// Load and Render PDF Highlights (Backend Provides Positions)
// ============================================================
export async function loadAndRenderPDFHighlights(
mediaItemId: string,
container: HTMLElement,
): Promise<void> {
const response = await fetch(`/api/media-items/${mediaItemId}/highlights`);
if (!response.ok) return [];
if (!response.ok) return;
const highlights: Highlight[] = await response.json();
const highlights: any[] = await response.json();
for (const highlight of highlights) {
renderPDFHighlight(container, highlight);
@@ -96,92 +116,39 @@ export async function loadAndRenderPDFHighlights(
function renderPDFHighlight(
container: HTMLElement,
highlight: Highlight,
highlight: any,
): void {
// Backend provides position data for PDF highlights
// Check which position format is available
const overlay = document.createElement("div");
overlay.className = "pdf-highlight-annotation";
overlay.dataset.highlightId = highlight.id;
overlay.style.backgroundColor = parseColor(highlight.color || "#ffff00");
if (
highlight.start_position &&
highlight.start_position.startsWith("pdf:page:")
) {
// Backend calculated page-based position
renderPDFHighlightByPosition(container, highlight);
} else if (highlight.percentage_start !== null) {
// Backend calculated percentage position
renderPDFHighlightByPercentage(container, highlight);
for (const rect of highlight.rects || []) {
const rectDiv = document.createElement("div");
rectDiv.className = "pdf-highlight-rect";
rectDiv.style.left = `${rect.x}px`;
rectDiv.style.top = `${rect.y}px`;
rectDiv.style.width = `${rect.width}px`;
rectDiv.style.height = `${rect.height}px`;
overlay.appendChild(rectDiv);
}
container.appendChild(overlay);
}
function renderPDFHighlightByPosition(
container: HTMLElement,
highlight: Highlight,
): void {
// Parse position string: "pdf:page:45:offset:123"
const match = highlight.start_position.match(/pdf:page:(\d+):offset:(\d+)/);
if (!match) return;
const pageNumber = parseInt(match[1], 10);
const offset = parseInt(match[2], 10);
// Find the page element
const pageElement = container.querySelector(
`[data-page-number="${pageNumber}"]`,
);
if (!pageElement) return;
// Get text content at offset
const textContent = pageElement.querySelector(".pdf-text-layer")?.textContent;
if (!textContent) return;
// Find the text at this offset
const textBefore = textContent.substring(0, offset);
const startChar = textBefore.length;
const endChar = startChar + (highlight.selection_text?.length || 10);
if (startChar < textContent.length && endChar <= textContent.length) {
applyHighlightToTextContent(
pageElement as HTMLElement,
startChar,
endChar,
highlight.color,
);
function parseColor(color: string): string {
if (color.startsWith("#")) {
const hex = color.slice(1);
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, 0.4)`;
}
return color;
}
function renderPDFHighlightByPercentage(
container: HTMLElement,
highlight: Highlight,
): void {
// Backend provides percentage - estimate position
const percentage = highlight.percentage_start || 0;
// Find spine item closest to this percentage
const totalPages = container.querySelectorAll("[data-page-number]").length;
const targetPage = Math.ceil(percentage * totalPages);
const pageElement = container.querySelector(
`[data-page-number="${targetPage}"]`,
);
if (pageElement) {
// Highlight entire page (coarse-grained)
applyHighlightStylesToElement(pageElement as HTMLElement, highlight.color);
}
}
function applyHighlightToTextContent(
element: HTMLElement,
startChar: number,
endChar: number,
color: string,
): void {
const text = element.textContent || "";
const before = text.substring(0, startChar);
const selection = text.substring(startChar, endChar);
const after = text.substring(endChar);
element.textContent = before + selection + after;
// Use a mark to wrap the selected text
element.innerHTML = `${before}<mark style="background-color: ${addAlphaToColor(color, 0.4)}">${selection}</mark>${after}`;
}
function getCurrentPDFPage(): number {
const pageElement = document.querySelector("[data-page-number]");
return pageElement ? parseInt(pageElement.getAttribute("data-page-number") || "1") : 1;
}
+71 -24
View File
@@ -1,7 +1,46 @@
// Track reading speed and update database
// Feature Registration Pattern implementation
// Reading speed tracker
// Procedural implementation (no OOP)
import type { ReaderContext } from "./core/reader-context";
export function init(context: ReaderContext): void {
let state: ReadingSpeedTrackerState | null = null;
context.events.on("reader:loaded", (detail: { mediaItemId: string }) => {
state = createReadingSpeedTracker(detail.mediaItemId);
});
context.events.on("reading-session:start", () => {
if (state) {
startReadingSession(state);
}
});
context.events.on("page-changed", () => {
if (state) {
recordPageTurn(state);
}
});
context.events.on("words-read", (detail: { wordCount: number }) => {
if (state) {
recordWordsRead(state, detail.wordCount);
}
});
context.events.on("reading-session:end", async () => {
if (state) {
await syncReadingSpeed(state);
}
});
context.events.on("reader:unload", async () => {
if (state) {
await syncReadingSpeed(state);
state = null;
}
});
}
interface ReadingSpeedTrackerState {
startTime: number | null;
@@ -26,12 +65,10 @@ function createReadingSpeedTracker(
function startReadingSession(
state: ReadingSpeedTrackerState,
): ReadingSpeedTrackerState {
return {
...state,
startTime: Date.now(),
pagesRead: 0,
wordsRead: 0,
};
state.startTime = Date.now();
state.pagesRead = 0;
state.wordsRead = 0;
return state;
}
function recordPageTurn(
@@ -39,25 +76,23 @@ function recordPageTurn(
): ReadingSpeedTrackerState {
if (!state.startTime) return state;
const newPagesRead = state.pagesRead + 1;
state.pagesRead += 1;
const now = Date.now();
if (newPagesRead % 5 === 0 || now - state.lastSync > 5 * 60 * 1000) {
syncReadingSpeed({ ...state, pagesRead: newPagesRead });
return { ...state, pagesRead: newPagesRead, lastSync: now };
if (state.pagesRead % 5 === 0 || now - state.lastSync > 5 * 60 * 1000) {
syncReadingSpeed(state);
state.lastSync = now;
}
return { ...state, pagesRead: newPagesRead };
return state;
}
function recordWordsRead(
state: ReadingSpeedTrackerState,
wordCount: number,
): ReadingSpeedTrackerState {
return {
...state,
wordsRead: state.wordsRead + wordCount,
};
state.wordsRead += wordCount;
return state;
}
async function syncReadingSpeed(
@@ -69,10 +104,22 @@ async function syncReadingSpeed(
const pagesPerMinute = state.pagesRead / minutesElapsed;
const wordsPerMinute = state.wordsRead / minutesElapsed;
await apiPut(`/readers/${state.mediaItemId}/reading-speed`, {
pages_per_minute: pagesPerMinute,
words_per_minute: wordsPerMinute,
pages_read: state.pagesRead,
total_reading_minutes: minutesElapsed,
});
}
try {
const token = localStorage.getItem("token");
await fetch(`/readers/${state.mediaItemId}/reading-speed`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
pages_per_minute: pagesPerMinute,
words_per_minute: wordsPerMinute,
pages_read: state.pagesRead,
total_reading_minutes: minutesElapsed,
}),
});
} catch (error) {
console.error("Failed to sync reading speed:", error);
}
}
+71 -39
View File
@@ -1,61 +1,95 @@
// Per-user settings with localStorage fallback
// Feature Registration Pattern implementation
import type { ReaderContext } from "./core/reader-context";
import { apiGet, apiPut } from "../api";
import { getToken, setItem, getItem } from "../storage";
import { getToken } from "../storage";
const SETTINGS_KEY = "reader_settings";
const LOCALSTORAGE_KEY = "reader_settings_local";
interface SettingsManager {
load(): Promise<ReaderSettings>;
save(settings: Partial<ReaderSettings>): Promise<void>;
sync(): Promise<void>; // Sync localStorage → DB
get(key: keyof ReaderSettings): any;
set(key: keyof ReaderSettings, value: any): Promise<void>;
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);
});
}
async function loadSettings(): Promise<ReaderSettings> {
const token = getToken();
if (!token) {
// Fallback to localStorage
const local = getItem(LOCALSTORAGE_KEY);
const local = localStorage.getItem(LOCALSTORAGE_KEY);
return local ? JSON.parse(local) : getDefaultSettings();
}
try {
const response = await apiGet("/readers/settings");
const settings = await response.json();
// Cache in localStorage
setItem(LOCALSTORAGE_KEY, JSON.stringify(settings));
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(settings));
return settings;
} catch (error) {
// Fallback to localStorage on error
const local = getItem(LOCALSTORAGE_KEY);
const local = localStorage.getItem(LOCALSTORAGE_KEY);
return local ? JSON.parse(local) : getDefaultSettings();
}
}
async function saveSettings(settings: Partial<ReaderSettings>): Promise<void> {
const token = getToken();
const current = await loadSettings();
const updated = { ...current, ...settings };
if (!token) {
// Save to localStorage only
const current = loadSettings();
const updated = { ...current, ...settings };
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
return;
}
try {
await apiPut("/readers/settings", settings);
// Update localStorage cache
const current = loadSettings();
const updated = { ...current, ...settings };
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
} catch (error) {
// Fallback to localStorage
const current = loadSettings();
const updated = { ...current, ...settings };
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
}
}
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);
} catch (error) {
console.error("Failed to sync settings:", error);
}
}
}
@@ -63,9 +97,9 @@ function getDefaultSettings(): ReaderSettings {
return {
chrome_behavior: "auto-hide",
progress_mode: "pages",
chrome_theme: "tokyo-night", // UI chrome: All 11 themes available
reading_theme: "dark", // Ebook text: 5 reading-optimized themes
reading_font: "literata", // Default reading font (designed for ebooks)
chrome_theme: "tokyo-night",
reading_theme: "dark",
reading_font: "literata",
tap_zone_size: 30,
auto_scroll: false,
panel_zoom_enabled: true,
@@ -75,8 +109,6 @@ function getDefaultSettings(): ReaderSettings {
double_page_spread: false,
reading_direction: "ltr",
hardware_acceleration: true,
// Dockable panel defaults by media type
panel_layout: {
toc: {
side: "left",
@@ -98,10 +130,10 @@ function getDefaultSettings(): ReaderSettings {
},
navigator: {
side: "right",
visible: true,
collapsed: false,
width_px: 200,
order: 1,
visible: false,
collapsed: true,
width_px: 280,
order: 3,
locked: false,
last_valid_side: "right",
},
@@ -110,11 +142,11 @@ function getDefaultSettings(): ReaderSettings {
visible: false,
collapsed: true,
width_px: 280,
order: 2,
order: 4,
locked: false,
last_valid_side: "right",
},
mobile_nav_visible: false,
mobile_nav_visible: true,
},
};
}
} as ReaderSettings;
}