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
+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;
}