refactor: Remove broken reader implementation for foliate-js migration
Remove 60+ files from the old reader implementation that relied on CSS columns pagination, which was fundamentally broken. This includes: - Comic/Manga format handlers (panel detection, reading direction) - PDF rendering, bookmarks, annotations, outlines - Reflowable content pagination (EPUB, FB2, TXT, HTML parsers) - UI components (gestures, keyboard shortcuts, panel dock system) - Core navigation and state management The old implementation used CSS columns for EPUB pagination, but this approach is fundamentally incompatible with horizontal book layouts because CSS columns fill vertically first, then wrap horizontally. This causes only 1 column to be created instead of the expected 92+. This cleanup prepares the codebase for foliate-js integration, which uses JavaScript-driven pagination with CFI-based positioning that actually works for book reading. Files removed: - formats/: comic/, manga/, pdf/, reflowable/ (65 files) - parsers/: EPUB, FB2, TXT, HTML (4 files) - ui/: gestures, keyboard shortcuts, panel dock, progress tracker (8 files) - core/: parser-manager, reader-navigation, reader-services, reader-state (4 files) - reader-shell.ts: Main reader orchestrator (565 lines) Total: 9,361 lines removed Reader functionality will be restored via foliate-js integration.
This commit is contained in:
@@ -1,166 +0,0 @@
|
||||
// Parser Manager - Routes files to appropriate parsers
|
||||
// Procedural style: Functions, not classes
|
||||
|
||||
// ============================================================
|
||||
// Parser Registry
|
||||
// ============================================================
|
||||
|
||||
const PARSER_REGISTRY: ParserEntry[] = [
|
||||
{
|
||||
format: "epub",
|
||||
mimeType: "application/epub+zip",
|
||||
extensions: [".epub"],
|
||||
side: "client",
|
||||
},
|
||||
{
|
||||
format: "fb2",
|
||||
mimeType: "application/fb2",
|
||||
extensions: [".fb2", ".fb2.zip"],
|
||||
side: "client",
|
||||
},
|
||||
{
|
||||
format: "txt",
|
||||
mimeType: "text/plain",
|
||||
extensions: [".txt"],
|
||||
side: "client",
|
||||
},
|
||||
{
|
||||
format: "html",
|
||||
mimeType: "text/html",
|
||||
extensions: [".html", ".htm"],
|
||||
side: "client",
|
||||
},
|
||||
{
|
||||
format: "mobi",
|
||||
mimeType: "application/x-mobipocket-ebook",
|
||||
extensions: [".mobi", ".azw"],
|
||||
side: "server",
|
||||
},
|
||||
{
|
||||
format: "azw3",
|
||||
mimeType: "application/vnd.amazon.mobi8-ebook",
|
||||
extensions: [".azw3"],
|
||||
side: "server",
|
||||
},
|
||||
{
|
||||
format: "docx",
|
||||
mimeType:
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
extensions: [".docx"],
|
||||
side: "server",
|
||||
},
|
||||
{
|
||||
format: "rtf",
|
||||
mimeType: "application/rtf",
|
||||
extensions: [".rtf"],
|
||||
side: "server",
|
||||
},
|
||||
];
|
||||
|
||||
interface ParserEntry {
|
||||
format: string;
|
||||
mimeType: string;
|
||||
extensions: string[];
|
||||
side: "client" | "server";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Parser Detection
|
||||
// ============================================================
|
||||
|
||||
export function detectParserFormat(
|
||||
mimeType: string,
|
||||
extension: string,
|
||||
): ParserEntry | null {
|
||||
return (
|
||||
PARSER_REGISTRY.find(
|
||||
(entry) =>
|
||||
entry.mimeType === mimeType ||
|
||||
entry.extensions.includes(extension.toLowerCase()),
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
export function requiresServerParsing(
|
||||
mimeType: string,
|
||||
extension: string,
|
||||
): boolean {
|
||||
const entry = detectParserFormat(mimeType, extension);
|
||||
return entry?.side === "server" || false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Main Parse Function (Router)
|
||||
// ============================================================
|
||||
|
||||
export async function parseEbook(
|
||||
file: Blob,
|
||||
mimeType: string,
|
||||
extension: string,
|
||||
): Promise<EbookCIF> {
|
||||
const entry = detectParserFormat(mimeType, extension);
|
||||
|
||||
if (!entry) {
|
||||
throw new Error(`Unsupported ebook format: ${mimeType}, ${extension}`);
|
||||
}
|
||||
|
||||
if (entry.side === "server") {
|
||||
return parseEbookOnServer(file, entry.format);
|
||||
} else {
|
||||
return parseEbookOnClient(file, entry.format);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Client-Side Parsing
|
||||
// ============================================================
|
||||
|
||||
async function parseEbookOnClient(
|
||||
file: Blob,
|
||||
format: string,
|
||||
): Promise<EbookCIF> {
|
||||
switch (format) {
|
||||
case "epub": {
|
||||
const { parseEPUB } = await import("../parsers/epub-parsers");
|
||||
return parseEPUB(file);
|
||||
}
|
||||
case "fb2": {
|
||||
const { parseFB2 } = await import("../parsers/fb2-parser");
|
||||
return parseFB2(file);
|
||||
}
|
||||
case "txt": {
|
||||
const { parseTXT } = await import("../parsers/txt-parser");
|
||||
return parseTXT(file);
|
||||
}
|
||||
case "html": {
|
||||
const { parseHTML } = await import("../parsers/html-parser");
|
||||
return parseHTML(file);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Client-side parser not implemented for: ${format}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Server-Side Parsing (API Call)
|
||||
// ============================================================
|
||||
|
||||
async function parseEbookOnServer(
|
||||
file: Blob,
|
||||
format: string,
|
||||
): Promise<EbookCIF> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("format", format);
|
||||
|
||||
const response = await fetch("/readers/parse", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server parsing failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
@@ -1,534 +0,0 @@
|
||||
import { getDefaultSettings } from "../settings-manager";
|
||||
import { getState, setState } from "./reader-state";
|
||||
import { readerEvents } from "./reader-events";
|
||||
import { updateReadingProgress } from "./reader-services";
|
||||
import { ComicReader, MangaReader, UniversalReader } from "../reader-shell";
|
||||
import * as reflowableNav from "../formats/reflowable/navigation";
|
||||
import * as progressTracker from "../formats/reflowable/progress-tracker";
|
||||
import * as contentRenderer from "../formats/reflowable/content-renderer";
|
||||
import { CurrentReader } from "./reader-context";
|
||||
|
||||
export function createNavigationAPI() {
|
||||
return {
|
||||
nextPage: () => {
|
||||
const state = getState();
|
||||
if (!state.currentReader) return;
|
||||
readerEvents.emit("beforePageChange", state.currentReader);
|
||||
|
||||
if (state.currentReader.type === "ebook") {
|
||||
// NEW: Use reflowable navigation
|
||||
const book = state.currentReader as UniversalReader;
|
||||
|
||||
if (!reflowableNav.canGoNext(book)) {
|
||||
console.log("canGoNext returned false:", {
|
||||
currentPage: book.position.currentPage,
|
||||
totalPages: book.pagination?.totalPages,
|
||||
pagination: book.pagination,
|
||||
});
|
||||
return; // Already at last page
|
||||
}
|
||||
|
||||
const { success, position, content } = reflowableNav.nextPage(book);
|
||||
|
||||
if (success) {
|
||||
const container = document.getElementById("reader-content");
|
||||
if (!container) return;
|
||||
|
||||
// Update position
|
||||
const updatedBook = progressTracker.updateCurrentPosition(
|
||||
book,
|
||||
position,
|
||||
);
|
||||
setState({ currentReader: updatedBook });
|
||||
|
||||
// Render content
|
||||
contentRenderer.renderPage(container, content);
|
||||
|
||||
// Update UI
|
||||
updatePageIndicator(updatedBook);
|
||||
sendProgressUpdate();
|
||||
// Emit progress event for page counter
|
||||
readerEvents.emit("progressUpdated", {
|
||||
currentPage: updatedBook.currentPage,
|
||||
totalPages: updatedBook.pagination?.totalPages || 0,
|
||||
percentage: updatedBook.position?.progress || 0,
|
||||
});
|
||||
}
|
||||
} else if (state.currentReader.type === "pdf") {
|
||||
// Keep existing PDF code (lines 45-51)
|
||||
const totalPages = state.readerMetadata?.total_pages || 0;
|
||||
if (state.currentReader.currentPage < totalPages) {
|
||||
state.currentReader.currentPage++;
|
||||
renderPDFPage();
|
||||
sendProgressUpdate();
|
||||
}
|
||||
} else if (
|
||||
state.currentReader.type === "comic" ||
|
||||
state.currentReader.type === "manga"
|
||||
) {
|
||||
if (
|
||||
state.currentReader.currentPage <
|
||||
state.currentReader.images.length - 1
|
||||
) {
|
||||
state.currentReader.currentPage++;
|
||||
renderComicPage();
|
||||
sendProgressUpdate();
|
||||
}
|
||||
}
|
||||
setState({ currentReader: state.currentReader });
|
||||
readerEvents.emit("afterPageChange", state.currentReader);
|
||||
},
|
||||
previousPage: () => {
|
||||
const state = getState();
|
||||
if (!state.currentReader) return;
|
||||
readerEvents.emit("beforePageChange", state.currentReader);
|
||||
|
||||
if (state.currentReader.type === "ebook") {
|
||||
// NEW: Use reflowable navigation
|
||||
const book = state.currentReader as UniversalReader;
|
||||
|
||||
if (!reflowableNav.canGoPrevious(book)) {
|
||||
return; // Already at first page
|
||||
}
|
||||
|
||||
const { success, position, content } = reflowableNav.previousPage(book);
|
||||
|
||||
if (success) {
|
||||
const container = document.getElementById("reader-content");
|
||||
if (!container) return;
|
||||
|
||||
// Update position
|
||||
const updatedBook = progressTracker.updateCurrentPosition(
|
||||
book,
|
||||
position,
|
||||
);
|
||||
setState({ currentReader: updatedBook });
|
||||
|
||||
// Render content
|
||||
contentRenderer.renderPage(container, content);
|
||||
|
||||
// Update UI
|
||||
updatePageIndicator(updatedBook);
|
||||
sendProgressUpdate();
|
||||
|
||||
// Emit progress event for page counter
|
||||
readerEvents.emit("progressUpdated", {
|
||||
currentPage: updatedBook.currentPage,
|
||||
totalPages: updatedBook.pagination?.totalPages || 0,
|
||||
percentage: updatedBook.position?.progress || 0,
|
||||
});
|
||||
}
|
||||
} else if (state.currentReader.type === "pdf") {
|
||||
if (state.currentReader.currentPage > 1) {
|
||||
state.currentReader.currentPage--;
|
||||
renderPDFPage();
|
||||
sendProgressUpdate();
|
||||
}
|
||||
} else if (
|
||||
state.currentReader.type === "comic" ||
|
||||
state.currentReader.type === "manga"
|
||||
) {
|
||||
if (state.currentReader.currentPage > 0) {
|
||||
state.currentReader.currentPage--;
|
||||
renderComicPage();
|
||||
sendProgressUpdate();
|
||||
}
|
||||
}
|
||||
setState({ currentReader: state.currentReader });
|
||||
readerEvents.emit("afterPageChange", state.currentReader);
|
||||
},
|
||||
goToPage: (page: number) => {
|
||||
const state = getState();
|
||||
if (!state.currentReader) return;
|
||||
readerEvents.emit("beforePageChange", state.currentReader);
|
||||
|
||||
if (state.currentReader.type === "ebook") {
|
||||
// NEW: Use reflowable navigation
|
||||
const book = state.currentReader as UniversalReader;
|
||||
|
||||
const { success, position, content } = reflowableNav.goToPage(
|
||||
book,
|
||||
page,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
const container = document.getElementById("reader-content");
|
||||
if (!container) return;
|
||||
|
||||
// Update position
|
||||
const updatedBook = progressTracker.updateCurrentPosition(
|
||||
book,
|
||||
position,
|
||||
);
|
||||
setState({ currentReader: updatedBook });
|
||||
|
||||
// Render content
|
||||
contentRenderer.renderPage(container, content);
|
||||
|
||||
// Update UI
|
||||
updatePageIndicator(updatedBook);
|
||||
sendProgressUpdate();
|
||||
}
|
||||
} else if (state.currentReader.type === "pdf") {
|
||||
if (page >= 1 && page <= (state.readerMetadata?.total_pages || 0)) {
|
||||
state.currentReader.currentPage = page;
|
||||
renderPDFPage();
|
||||
sendProgressUpdate();
|
||||
}
|
||||
} else if (
|
||||
state.currentReader &&
|
||||
isComicOrMangaReader(state.currentReader)
|
||||
) {
|
||||
if (page >= 0 && page < state.currentReader.images.length) {
|
||||
state.currentReader.currentPage = page;
|
||||
renderComicPage();
|
||||
sendProgressUpdate();
|
||||
}
|
||||
}
|
||||
setState({ currentReader: state.currentReader });
|
||||
readerEvents.emit("pageChanged", page);
|
||||
readerEvents.emit("afterPageChange", state.currentReader);
|
||||
},
|
||||
goToChapter: (chapterIndex: number) => {
|
||||
const state = getState();
|
||||
if (!state.readerMetadata?.chapter_metadata?.chapters) return;
|
||||
if (state.currentReader?.type !== "ebook") return;
|
||||
const chapters = state.readerMetadata.chapter_metadata.chapters;
|
||||
if (chapterIndex < 0 || chapterIndex >= chapters.length) return;
|
||||
const chapter = chapters[chapterIndex];
|
||||
if (chapter.spine_index !== undefined) {
|
||||
state.currentReader.currentSpineIndex = chapter.spine_index;
|
||||
setState({ currentReader: state.currentReader });
|
||||
renderSpineItem();
|
||||
sendProgressUpdate();
|
||||
} else {
|
||||
const pageAPI = createNavigationAPI();
|
||||
pageAPI.goToPage(chapter.start_page);
|
||||
}
|
||||
readerEvents.emit("chapterChanged", chapter);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isComicOrMangaReader(
|
||||
reader: CurrentReader,
|
||||
): reader is ComicReader | MangaReader {
|
||||
return reader.type === "comic" || reader.type === "manga";
|
||||
}
|
||||
|
||||
// Helper function to update page indicator
|
||||
function updatePageIndicator(book: UniversalReader): void {
|
||||
const { currentPage, totalPages, percentage } =
|
||||
progressTracker.calculateProgress(book);
|
||||
|
||||
const container = document.getElementById("reader-container");
|
||||
if (!container) return;
|
||||
|
||||
// Update page display
|
||||
const pageDisplay = document.querySelector(".page-display");
|
||||
if (pageDisplay) {
|
||||
pageDisplay.textContent = `Page ${currentPage} of ${totalPages}`;
|
||||
}
|
||||
|
||||
// Update progress bar
|
||||
const progressBar = document.querySelector(
|
||||
".progress-bar-fill",
|
||||
) as HTMLElement;
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${percentage}%`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializePageCalculation() {
|
||||
setupScrollTracking();
|
||||
console.log("Page tracking initialized (CSS columns mode)");
|
||||
}
|
||||
function setupScrollTracking(): void {
|
||||
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
document.addEventListener(
|
||||
"scroll",
|
||||
() => {
|
||||
const container = document.getElementById("reader-content");
|
||||
if (!container) return;
|
||||
const state = getState();
|
||||
if (!state.currentReader || state.currentReader.type !== "ebook") return;
|
||||
if (scrollTimeout) clearTimeout(scrollTimeout);
|
||||
scrollTimeout = setTimeout(() => {
|
||||
updatePageFromScroll(container);
|
||||
}, 50);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
}
|
||||
function updatePageFromScroll(container: HTMLElement): void {
|
||||
const state = getState();
|
||||
if (!state.currentReader || state.currentReader.type !== "ebook") return;
|
||||
const scrollTop = container.scrollTop;
|
||||
const contentHeight = container.scrollHeight;
|
||||
const percentage = contentHeight > 0 ? scrollTop / contentHeight : 0;
|
||||
setState({
|
||||
currentReader: {
|
||||
...state.currentReader,
|
||||
currentScrollPosition: scrollTop,
|
||||
},
|
||||
});
|
||||
readerEvents.emit("progressUpdated", {
|
||||
currentPage: state.currentReader.currentPage,
|
||||
totalPages: state.currentReader.pagination?.totalPages || 0,
|
||||
percentage,
|
||||
scrollTop,
|
||||
});
|
||||
}
|
||||
|
||||
export async function renderSpineItem(): Promise<void> {
|
||||
const state = getState();
|
||||
if (state.currentReader?.type !== "ebook") return;
|
||||
const container = document.getElementById("reader-content");
|
||||
if (!container) return;
|
||||
const spineItem =
|
||||
state.currentReader.cif.spine[state.currentReader.currentSpineIndex];
|
||||
if (!spineItem) return;
|
||||
const resources = state.currentReader.cif.resources;
|
||||
const contentBlob = resources?.get(spineItem.content);
|
||||
if (!contentBlob) {
|
||||
console.error("Spine item content not found:", spineItem.content);
|
||||
container.innerHTML = `<p>Error: Could not load chapter content</p>`;
|
||||
return;
|
||||
}
|
||||
let currentPageContent = await contentBlob.text();
|
||||
const modifiedContent = rewriteImageUrls(
|
||||
currentPageContent,
|
||||
state.currentReader.cif.resources,
|
||||
);
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(modifiedContent, "text/html");
|
||||
const imgElements = Array.from(doc.querySelectorAll("img"));
|
||||
for (const img of imgElements) {
|
||||
const src = img.getAttribute("src");
|
||||
if (!src) continue;
|
||||
const blob = findImageInResources(state.currentReader.cif.resources, src);
|
||||
if (blob) {
|
||||
img.setAttribute("src", URL.createObjectURL(blob));
|
||||
}
|
||||
}
|
||||
const svgImgElements = Array.from(doc.querySelectorAll("image"));
|
||||
for (const img of svgImgElements) {
|
||||
const src = img.getAttribute("xlink:href");
|
||||
if (!src) continue;
|
||||
const blob = findImageInResources(state.currentReader.cif.resources, src);
|
||||
if (blob) {
|
||||
img.setAttribute("xlink:href", URL.createObjectURL(blob));
|
||||
}
|
||||
}
|
||||
container.innerHTML = `<div class="ebook-content">${doc.body.innerHTML}</div>`;
|
||||
applyReaderTheme();
|
||||
applyTypography();
|
||||
requestAnimationFrame(() => {
|
||||
updatePageFromScroll(container);
|
||||
});
|
||||
}
|
||||
|
||||
function rewriteImageUrls(
|
||||
htmlContent: string,
|
||||
resources: Map<string, Blob>,
|
||||
): string {
|
||||
// Find all img tags and rewrite their src to blob URLs
|
||||
const imgRegex = /<img\s+[^>]*src="([^"]+)"[^>]*>/gi;
|
||||
return htmlContent.replace(imgRegex, (match, src) => {
|
||||
// Try to find the image in resources
|
||||
const imageBlob = findImageInResources(resources, src);
|
||||
|
||||
if (imageBlob) {
|
||||
const blobUrl = URL.createObjectURL(imageBlob);
|
||||
return match.replace(src, blobUrl);
|
||||
}
|
||||
return match; // Keep original if not found
|
||||
});
|
||||
}
|
||||
|
||||
export function findImageInResources(
|
||||
resources: Map<string, Blob>,
|
||||
src: string,
|
||||
): Blob | undefined {
|
||||
// Try full path as stored
|
||||
if (resources.has(src)) return resources.get(src);
|
||||
// Try relative path (everything after first /)
|
||||
const firstSlash = src.indexOf("/");
|
||||
if (firstSlash > 0) {
|
||||
const relativePath = src.substring(firstSlash + 1);
|
||||
if (resources.has(relativePath)) return resources.get(relativePath);
|
||||
}
|
||||
// Try filename only
|
||||
const filename = src.split("/").pop();
|
||||
if (filename && resources.has(filename)) return resources.get(filename);
|
||||
// Try without extension
|
||||
const withoutExt = filename?.replace(/\.[^.]+$/, "");
|
||||
if (withoutExt && resources.has(withoutExt)) return resources.get(withoutExt);
|
||||
// Try with common extensions
|
||||
const extensions = [".jpg", ".jpeg", ".gif", ".webp", ".svg", ".png"];
|
||||
for (const ext of extensions) {
|
||||
const withExt = withoutExt + ext;
|
||||
if (resources.has(withExt)) return resources.get(withExt);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function renderPDFPage() {
|
||||
const state = getState();
|
||||
if (state.currentReader?.type !== "pdf") return;
|
||||
|
||||
const container = document.getElementById("reader-content");
|
||||
if (!container) return;
|
||||
|
||||
const { getPDFPage } = await import("../formats/pdf/pdfjs-wrapper");
|
||||
|
||||
try {
|
||||
const page = await getPDFPage(state.currentReader.currentPage);
|
||||
const viewport = page.getViewport({ scale: 1.5 });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
canvas.className = "pdf-page mx-auto";
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
await page.render({
|
||||
canvasContext: ctx,
|
||||
viewport: viewport,
|
||||
canvas: canvas,
|
||||
}).promise;
|
||||
container.innerHTML = "";
|
||||
container.appendChild(canvas);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to render PDF page:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderComicPage() {
|
||||
const state = getState();
|
||||
if (
|
||||
state.currentReader?.type !== "comic" &&
|
||||
state.currentReader?.type !== "manga"
|
||||
)
|
||||
return;
|
||||
|
||||
const container = document.getElementById("reader-content");
|
||||
if (
|
||||
!container ||
|
||||
!state.currentReader.images[state.currentReader.currentPage]
|
||||
)
|
||||
return;
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = URL.createObjectURL(
|
||||
state.currentReader.images[state.currentReader.currentPage],
|
||||
);
|
||||
img.className = "w-full h-contain object-contain";
|
||||
img.alt = `Page ${state.currentReader.currentPage + 1}`;
|
||||
container.innerHTML = "";
|
||||
container.appendChild(img);
|
||||
}
|
||||
|
||||
function applyReaderTheme() {
|
||||
const settings = getDefaultSettings();
|
||||
const container = document.getElementById("reader-content");
|
||||
if (!container) return;
|
||||
|
||||
container.classList.add(`theme-${settings.reading_theme}`);
|
||||
}
|
||||
|
||||
function applyTypography() {
|
||||
const settings = getDefaultSettings();
|
||||
const container = document.getElementById("reader-content");
|
||||
if (!container) return;
|
||||
|
||||
container.style.fontSize = `${settings.font_size}px`;
|
||||
container.style.lineHeight = settings.line_height.toString();
|
||||
container.style.fontFamily = getFontStack(settings.reading_font);
|
||||
}
|
||||
|
||||
function getFontStack(font: string): string {
|
||||
const stacks: 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 stacks[font] || stacks["literata"];
|
||||
}
|
||||
|
||||
function sendProgressUpdate(): void {
|
||||
const state = getState();
|
||||
if (!state.currentReader || !state.readerMetadata) return;
|
||||
const mediaItemId = state.readerMetadata.id;
|
||||
if (!mediaItemId) {
|
||||
console.warn("No mediaItemId available for progress update");
|
||||
return;
|
||||
}
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
let percentage = 0;
|
||||
if (state.currentReader.type === "ebook") {
|
||||
const book = state.currentReader as UniversalReader;
|
||||
const posData = progressTracker.getPositionForSave(book);
|
||||
|
||||
currentPage = posData.page;
|
||||
totalPages = book.pagination?.totalPages || 1;
|
||||
percentage = posData.progress;
|
||||
|
||||
// Correct: 3 separate parameters
|
||||
updateReadingProgress(
|
||||
mediaItemId, // 1st param: mediaItemId
|
||||
{
|
||||
// 2nd param: ReadingProgress object
|
||||
current_page: posData.page,
|
||||
total_pages: totalPages,
|
||||
cfi: posData.cfi,
|
||||
percentage,
|
||||
last_read_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
// 3rd param (optional): ExtendedProgress
|
||||
percentage,
|
||||
},
|
||||
);
|
||||
} else if (state.currentReader.type === "pdf") {
|
||||
totalPages = state.readerMetadata.total_pages || 1;
|
||||
currentPage = state.currentReader.currentPage;
|
||||
percentage = currentPage / totalPages;
|
||||
|
||||
updateReadingProgress(
|
||||
mediaItemId,
|
||||
{
|
||||
current_page: currentPage,
|
||||
total_pages: totalPages,
|
||||
last_read_at: new Date().toISOString(),
|
||||
percentage,
|
||||
},
|
||||
{ percentage },
|
||||
);
|
||||
} else if (
|
||||
state.currentReader.type === "comic" ||
|
||||
state.currentReader.type === "manga"
|
||||
) {
|
||||
totalPages = state.currentReader.images.length;
|
||||
currentPage = state.currentReader.currentPage;
|
||||
percentage = currentPage / totalPages;
|
||||
|
||||
updateReadingProgress(
|
||||
mediaItemId,
|
||||
{
|
||||
current_page: currentPage,
|
||||
total_pages: totalPages,
|
||||
last_read_at: new Date().toISOString(),
|
||||
percentage,
|
||||
},
|
||||
{ percentage },
|
||||
);
|
||||
}
|
||||
readerEvents.emit("progressUpdated", { currentPage, totalPages, percentage });
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { apiPut, ReadingProgress } from "../../api";
|
||||
|
||||
interface ExtendedProgress {
|
||||
character?: number;
|
||||
chapter?: number;
|
||||
percentage?: number;
|
||||
}
|
||||
|
||||
export async function updateReadingProgress(
|
||||
mediaItemId: string,
|
||||
progress: ReadingProgress,
|
||||
extended?: ExtendedProgress,
|
||||
): Promise<void> {
|
||||
if (!mediaItemId || mediaItemId === "undefined") {
|
||||
console.warn("Skipping progress update - no valid mediaItemId");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: ReadingProgress = {
|
||||
current_page: progress.current_page,
|
||||
total_pages: progress.total_pages,
|
||||
cfi: progress.cfi || "",
|
||||
percentage: extended?.percentage || 0,
|
||||
last_read_at: new Date().toISOString(),
|
||||
};
|
||||
const response = await apiPut(
|
||||
`/media-items/${mediaItemId}/progress`,
|
||||
payload,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const clonedResponse = response.clone();
|
||||
const errorText = await clonedResponse.text();
|
||||
console.warn("Progress update failed:", clonedResponse.status, errorText);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function getChapterNavigation(chapters: Chapter[]) {
|
||||
return {
|
||||
getNextChapter: (currentPage: number) => {
|
||||
for (let i = 0; i < chapters.length - 1; i++) {
|
||||
const chapter = chapters[i];
|
||||
const nextChapter = chapters[i + 1];
|
||||
|
||||
if (
|
||||
currentPage >= chapter.start_page &&
|
||||
currentPage < nextChapter.start_page
|
||||
) {
|
||||
return nextChapter.start_page;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
getPreviousChapter: (currentPage: number) => {
|
||||
for (let i = 1; i < chapters.length; i++) {
|
||||
const chapter = chapters[i];
|
||||
|
||||
if (
|
||||
currentPage >= chapter.start_page &&
|
||||
currentPage < chapter.start_page + chapter.page_count
|
||||
) {
|
||||
return chapters[i - 1].start_page;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPage < chapters[0].start_page) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chapters[0].start_page;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { ReaderState } from "./reader-context";
|
||||
|
||||
type CurrentReader = import("./reader-context").CurrentReader;
|
||||
|
||||
let currentState: ReaderState = {
|
||||
currentReader: null,
|
||||
readerMetadata: null,
|
||||
};
|
||||
|
||||
export function getState(): ReaderState {
|
||||
return { ...currentState };
|
||||
}
|
||||
|
||||
export function setState(updates: Partial<ReaderState>): void {
|
||||
const oldState = { ...currentState };
|
||||
currentState = { ...currentState, ...updates };
|
||||
|
||||
// Emit events for significant changes
|
||||
if (
|
||||
updates.currentReader &&
|
||||
oldState.currentReader !== updates.currentReader
|
||||
) {
|
||||
readerEvents.emit("readerReady", currentState.currentReader);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentReader(): CurrentReader | null {
|
||||
return currentState.currentReader;
|
||||
}
|
||||
|
||||
export function getReaderMetadata(): ReaderMetadata | null {
|
||||
return currentState.readerMetadata;
|
||||
}
|
||||
|
||||
export function getCurrentPage(): number {
|
||||
const reader = currentState.currentReader;
|
||||
if (!reader) return 0;
|
||||
if (reader.type === "ebook") {
|
||||
return reader.currentPage ?? reader.currentSpineIndex + 1;
|
||||
}
|
||||
return reader.currentPage;
|
||||
}
|
||||
|
||||
import { readerEvents } from "./reader-events";
|
||||
@@ -1,147 +0,0 @@
|
||||
// Background color options for manga/comics
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import { 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";
|
||||
|
||||
interface BackgroundColorState {
|
||||
current: BackgroundColor;
|
||||
customColor: string;
|
||||
}
|
||||
|
||||
const backgroundColors: Record<BackgroundColor, string> = {
|
||||
black: "#000000",
|
||||
white: "#ffffff",
|
||||
gray: "#333333",
|
||||
sepia: "#f4ecd8",
|
||||
custom: "",
|
||||
};
|
||||
|
||||
function createBackgroundColorState(
|
||||
initial: BackgroundColor = "black",
|
||||
): BackgroundColorState {
|
||||
const saved = localStorage.getItem(
|
||||
"reader-background-color",
|
||||
) as BackgroundColor;
|
||||
return {
|
||||
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 {
|
||||
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;
|
||||
if (viewer) {
|
||||
viewer.style.backgroundColor = bgColor;
|
||||
}
|
||||
|
||||
localStorage.setItem("reader-background-color", color);
|
||||
|
||||
const picker = document.querySelector(".background-color-picker");
|
||||
if (picker) {
|
||||
updateBackgroundColorUI(picker as HTMLElement, state);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
function renderBackgroundColorPicker(
|
||||
container: HTMLElement,
|
||||
state: BackgroundColorState,
|
||||
): void {
|
||||
const existing = container.querySelector(".background-color-picker");
|
||||
existing?.remove();
|
||||
|
||||
const picker = document.createElement("div");
|
||||
picker.className =
|
||||
"background-color-picker fixed bottom-24 left-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex gap-2 z-40";
|
||||
|
||||
const colors: BackgroundColor[] = ["black", "white", "gray", "sepia"];
|
||||
|
||||
colors.forEach((color) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = `w-8 h-8 rounded-full border-2 ${
|
||||
state.current === color ? "border-blue-500" : "border-transparent"
|
||||
}`;
|
||||
btn.style.backgroundColor = backgroundColors[color];
|
||||
btn.title = color.charAt(0).toUpperCase() + color.slice(1);
|
||||
btn.addEventListener("click", () => {
|
||||
setBackgroundColor(state, color);
|
||||
updateBackgroundColorUI(picker, state);
|
||||
});
|
||||
picker.appendChild(btn);
|
||||
});
|
||||
|
||||
container.appendChild(picker);
|
||||
}
|
||||
|
||||
function updateBackgroundColorUI(
|
||||
container: HTMLElement,
|
||||
state: BackgroundColorState,
|
||||
): void {
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const colors: BackgroundColor[] = ["black", "white", "gray", "sepia"];
|
||||
|
||||
buttons.forEach((btn, index) => {
|
||||
btn.classList.toggle("border-blue-500", colors[index] === state.current);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
// Chapter markers for manga/comics
|
||||
// Visual indicators for chapter boundaries
|
||||
// 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;
|
||||
pageStart: number;
|
||||
pageEnd: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface ChapterMarkerState {
|
||||
chapters: ChapterInfo[];
|
||||
currentChapter: number;
|
||||
showMarkers: boolean;
|
||||
}
|
||||
|
||||
function createChapterMarkerState(
|
||||
chapters: ChapterInfo[],
|
||||
currentPage: number,
|
||||
): ChapterMarkerState {
|
||||
const currentChapter =
|
||||
chapters.find((c) => currentPage >= c.pageStart && currentPage <= c.pageEnd)
|
||||
?.chapterNumber || 1;
|
||||
|
||||
return {
|
||||
chapters,
|
||||
currentChapter,
|
||||
showMarkers: true,
|
||||
};
|
||||
}
|
||||
|
||||
function renderChapterMarkers(
|
||||
container: HTMLElement,
|
||||
state: ChapterMarkerState,
|
||||
): void {
|
||||
if (!state.showMarkers) return;
|
||||
|
||||
const markersContainer = document.createElement("div");
|
||||
markersContainer.className =
|
||||
"chapter-markers absolute left-0 right-0 pointer-events-none z-10";
|
||||
|
||||
state.chapters.forEach((chapter) => {
|
||||
const marker = document.createElement("div");
|
||||
marker.className =
|
||||
"chapter-marker flex items-center gap-2 text-sm text-gray-400";
|
||||
|
||||
const isCurrentChapter = chapter.chapterNumber === state.currentChapter;
|
||||
|
||||
marker.style.position = "absolute";
|
||||
marker.style.top = `${((chapter.pageStart - 1) / 100) * 100}%`;
|
||||
marker.style.left = "10px";
|
||||
|
||||
marker.innerHTML = `
|
||||
<span class="chapter-number ${isCurrentChapter ? "text-blue-400 font-bold" : ""}">
|
||||
${chapter.title || `Chapter ${chapter.chapterNumber}`}
|
||||
</span>
|
||||
<span class="page-number text-xs">p.${chapter.pageStart}</span>
|
||||
${isCurrentChapter ? '<span class="current-indicator">←</span>' : ""}
|
||||
`;
|
||||
|
||||
markersContainer.appendChild(marker);
|
||||
});
|
||||
|
||||
const existing = container.querySelector(".chapter-markers");
|
||||
existing?.remove();
|
||||
container.appendChild(markersContainer);
|
||||
}
|
||||
|
||||
function updateCurrentChapter(
|
||||
state: ChapterMarkerState,
|
||||
currentPage: number,
|
||||
): ChapterMarkerState {
|
||||
const currentChapter =
|
||||
state.chapters.find(
|
||||
(c) => currentPage >= c.pageStart && currentPage <= c.pageEnd,
|
||||
)?.chapterNumber || state.currentChapter;
|
||||
|
||||
if (currentChapter !== state.currentChapter) {
|
||||
state.currentChapter = currentChapter;
|
||||
|
||||
const markers = document.querySelector(".chapter-markers");
|
||||
if (markers) {
|
||||
renderChapterMarkers(markers.parentElement!, state);
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function toggleChapterMarkers(state: ChapterMarkerState): ChapterMarkerState {
|
||||
state.showMarkers = !state.showMarkers;
|
||||
|
||||
const markers = document.querySelector(".chapter-markers");
|
||||
if (markers) {
|
||||
markers.classList.toggle("hidden", !state.showMarkers);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function scrollToChapter(
|
||||
state: ChapterMarkerState,
|
||||
chapterNumber: number,
|
||||
): void {
|
||||
const chapter = state.chapters.find((c) => c.chapterNumber === chapterNumber);
|
||||
if (chapter) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("navigate-to-page", {
|
||||
detail: { page: chapter.pageStart },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
// Comic/Manga Reader - Image-based pages
|
||||
// Handles CBZ, comic archives, image directories
|
||||
interface ComicReader {
|
||||
type: "comic";
|
||||
images: Blob[];
|
||||
currentPage: number;
|
||||
}
|
||||
interface MangaReader {
|
||||
type: "manga";
|
||||
images: Blob[];
|
||||
currentPage: number;
|
||||
readingDirection: "rtl" | "vertical";
|
||||
}
|
||||
// ============================================================
|
||||
// Comic Reader Initialization
|
||||
// ============================================================
|
||||
export async function initializeComicReader(
|
||||
metadata: ReaderMetadata,
|
||||
): Promise<ComicReader> {
|
||||
const response = await fetch(metadata.file_path);
|
||||
const archiveBlob = await response.blob();
|
||||
// Parse comic archive (CBZ) or image directory
|
||||
const images = await parseComicArchive(archiveBlob);
|
||||
return {
|
||||
type: "comic",
|
||||
images,
|
||||
currentPage: 1,
|
||||
};
|
||||
}
|
||||
// ============================================================
|
||||
// Manga Reader Initialization
|
||||
// ============================================================
|
||||
export async function initializeMangaReader(
|
||||
metadata: ReaderMetadata,
|
||||
): Promise<MangaReader> {
|
||||
const response = await fetch(metadata.file_path);
|
||||
const archiveBlob = await response.blob();
|
||||
const images = await parseComicArchive(archiveBlob);
|
||||
return {
|
||||
type: "manga",
|
||||
images,
|
||||
currentPage: 1,
|
||||
readingDirection: "rtl", // Default for manga
|
||||
};
|
||||
}
|
||||
// ============================================================
|
||||
// Comic Archive Parser
|
||||
// ============================================================
|
||||
async function parseComicArchive(archiveBlob: Blob): Promise<Blob[]> {
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = await JSZip.loadAsync(archiveBlob);
|
||||
const images: Blob[] = [];
|
||||
// Get all image files from archive
|
||||
const files = Object.keys(zip.files).filter((filename) =>
|
||||
filename.match(/\.(jpg|jpeg|png|gif|webp)$/i),
|
||||
);
|
||||
// Sort files naturally (page-01.jpg, page-02.jpg, etc.)
|
||||
files.sort((a, b) => {
|
||||
const aName = a.split("/").pop() || a;
|
||||
const bName = b.split("/").pop() || b;
|
||||
return aName.localeCompare(bName, undefined, { numeric: true });
|
||||
});
|
||||
// Extract images
|
||||
for (const file of files) {
|
||||
const fileData = await zip.file(file)?.async("blob");
|
||||
if (fileData) {
|
||||
images.push(fileData);
|
||||
}
|
||||
}
|
||||
return images;
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
// Lazy-loading page cache with 5-page ahead prefetch
|
||||
// Shared by both comic and manga readers
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import { ReaderContext } from "../../core/reader-context";
|
||||
import { detectPanels } from "./panel-detection.service";
|
||||
|
||||
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;
|
||||
mediaItemId: string;
|
||||
panelData: Map<number, { panels: any[]; method: string; confidence: number }>;
|
||||
}
|
||||
|
||||
export function createPageCache(mediaItemId: string): PageCacheState {
|
||||
return {
|
||||
cache: new Map(),
|
||||
loading: new Set(),
|
||||
maxAhead: 5,
|
||||
mediaItemId,
|
||||
panelData: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCachedPage(
|
||||
state: PageCacheState,
|
||||
pageNumber: number,
|
||||
): Promise<PageCacheState & { page: HTMLImageElement }> {
|
||||
if (state.cache.has(pageNumber)) {
|
||||
return { ...state, page: state.cache.get(pageNumber)! };
|
||||
}
|
||||
|
||||
if (state.loading.has(pageNumber)) {
|
||||
return new Promise((resolve) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (state.cache.has(pageNumber)) {
|
||||
clearInterval(checkInterval);
|
||||
resolve({ ...state, page: state.cache.get(pageNumber)! });
|
||||
}
|
||||
}, 100);
|
||||
}) as Promise<PageCacheState & { page: HTMLImageElement }>;
|
||||
}
|
||||
|
||||
state.loading.add(pageNumber);
|
||||
|
||||
const img = await loadComicPage(state, pageNumber);
|
||||
|
||||
state.cache.set(pageNumber, img);
|
||||
state.loading.delete(pageNumber);
|
||||
|
||||
prefetchPages(state, pageNumber + 1);
|
||||
cleanupPageCache(state, pageNumber);
|
||||
|
||||
return { ...state, page: img };
|
||||
}
|
||||
|
||||
export async function loadComicPage(
|
||||
state: PageCacheState,
|
||||
pageNumber: number,
|
||||
): Promise<HTMLImageElement> {
|
||||
const token = localStorage.getItem("token");
|
||||
const response = await fetch(
|
||||
`/readers/${state.mediaItemId}/pages/${pageNumber}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load page ${pageNumber}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(blob);
|
||||
await new Promise((resolve) => {
|
||||
img.onload = resolve;
|
||||
});
|
||||
return img;
|
||||
}
|
||||
|
||||
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) => {
|
||||
state.cache.set(i, img);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupPageCache(
|
||||
state: PageCacheState,
|
||||
currentPage: number,
|
||||
): PageCacheState {
|
||||
const keepPages = 10;
|
||||
const newCache = new Map(state.cache);
|
||||
|
||||
for (const [page] of state.cache) {
|
||||
if (page < currentPage - keepPages) {
|
||||
newCache.delete(page);
|
||||
}
|
||||
}
|
||||
|
||||
state.cache = newCache;
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function detectPagePanels(
|
||||
state: PageCacheState,
|
||||
pageNumber: number,
|
||||
): Promise<any[]> {
|
||||
if (state.panelData?.has(pageNumber)) {
|
||||
return state.panelData.get(pageNumber)!.panels;
|
||||
}
|
||||
|
||||
let image: HTMLImageElement;
|
||||
if (state.cache.has(pageNumber)) {
|
||||
image = state.cache.get(pageNumber)!;
|
||||
} else {
|
||||
image = await loadComicPage(state, pageNumber);
|
||||
}
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const result = await detectPanels(imageData, true);
|
||||
|
||||
if (!state.panelData) {
|
||||
state.panelData = new Map();
|
||||
}
|
||||
state.panelData.set(pageNumber, result);
|
||||
|
||||
return result.panels;
|
||||
}
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
// Page order presets for manga/comics
|
||||
// Auto-detect Japanese vs Western reading order
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import { 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";
|
||||
|
||||
interface PageOrderConfig {
|
||||
mode: PageOrderMode;
|
||||
detectedOrder: PageOrderMode;
|
||||
userOverride: boolean;
|
||||
}
|
||||
|
||||
interface PageOrderState {
|
||||
config: PageOrderConfig;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
function detectPageOrder(pageNames: string[]): PageOrderMode {
|
||||
if (pageNames.length < 2) return "western";
|
||||
|
||||
const firstPage = pageNames[0].toLowerCase();
|
||||
const lastPage = pageNames[pageNames.length - 1].toLowerCase();
|
||||
|
||||
const hasFrontCover = /cover|front|001/.test(firstPage);
|
||||
const hasBackCover = /back|end|最后的/.test(lastPage);
|
||||
|
||||
if (hasFrontCover && !hasBackCover) {
|
||||
return "western";
|
||||
}
|
||||
if (hasBackCover && !hasFrontCover) {
|
||||
return "japanese";
|
||||
}
|
||||
|
||||
const chapterMatches = pageNames.filter((n) => /ch-\d+|chapter/i.test(n));
|
||||
if (chapterMatches.length > 0) {
|
||||
const firstChapter = chapterMatches[0];
|
||||
const pageNum = parseInt(firstChapter.match(/\d+/)?.[0] || "0");
|
||||
return pageNum > 0 ? "western" : "japanese";
|
||||
}
|
||||
|
||||
return "western";
|
||||
}
|
||||
|
||||
function createPageOrderState(
|
||||
totalPages: number,
|
||||
pageNames: string[],
|
||||
): PageOrderState {
|
||||
const detectedOrder = detectPageOrder(pageNames);
|
||||
|
||||
return {
|
||||
config: {
|
||||
mode: "auto",
|
||||
detectedOrder,
|
||||
userOverride: false,
|
||||
},
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
function setPageOrderMode(
|
||||
state: PageOrderState,
|
||||
mode: PageOrderMode,
|
||||
context: ReaderContext,
|
||||
): PageOrderState {
|
||||
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 {
|
||||
if (state.config.mode === "auto") {
|
||||
return state.config.detectedOrder;
|
||||
}
|
||||
return state.config.mode;
|
||||
}
|
||||
|
||||
function reorderPages(state: PageOrderState, pageNumbers: number[]): number[] {
|
||||
const order = getPageOrder(state);
|
||||
|
||||
if (order === "japanese") {
|
||||
return [...pageNumbers].reverse();
|
||||
}
|
||||
|
||||
return pageNumbers;
|
||||
}
|
||||
|
||||
function getDisplayPageNumber(
|
||||
state: PageOrderState,
|
||||
actualPage: number,
|
||||
): number {
|
||||
const order = getPageOrder(state);
|
||||
|
||||
if (order === "japanese") {
|
||||
return state.totalPages - actualPage + 1;
|
||||
}
|
||||
|
||||
return actualPage;
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
// Page slider/scrubber for quick navigation
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import { 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;
|
||||
totalPages: number;
|
||||
container: HTMLElement;
|
||||
}
|
||||
|
||||
function createPageScrubber(
|
||||
container: HTMLElement,
|
||||
currentPage: number,
|
||||
totalPages: number,
|
||||
): PageScrubberState {
|
||||
const state: PageScrubberState = {
|
||||
currentPage,
|
||||
totalPages,
|
||||
container,
|
||||
};
|
||||
|
||||
renderPageScrubber(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function renderPageScrubber(state: PageScrubberState): void {
|
||||
const existing = state.container.querySelector(".page-scrubber");
|
||||
existing?.remove();
|
||||
|
||||
const scrubber = document.createElement("div");
|
||||
scrubber.className =
|
||||
"page-scrubber fixed bottom-20 left-1/2 transform -translate-x-1/2 bg-gray-900 bg-opacity-90 rounded-full px-4 py-2 flex items-center gap-4 z-40";
|
||||
scrubber.innerHTML = `
|
||||
<span class="page-label">${state.currentPage}</span>
|
||||
<input
|
||||
type="range"
|
||||
class="page-slider w-64 h-2 bg-gray-700 rounded-full appearance-none cursor-pointer"
|
||||
min="1"
|
||||
max="${state.totalPages}"
|
||||
value="${state.currentPage}"
|
||||
/>
|
||||
<span class="page-total">${state.totalPages}</span>
|
||||
`;
|
||||
|
||||
const slider = scrubber.querySelector(".page-slider") as HTMLInputElement;
|
||||
slider.addEventListener("input", (e) => {
|
||||
const targetPage = parseInt((e.target as HTMLInputElement).value);
|
||||
updatePageScrubber(state, targetPage);
|
||||
});
|
||||
|
||||
slider.addEventListener("change", () => {
|
||||
const targetPage = parseInt(slider.value);
|
||||
dispatchPageNavigationEvent(targetPage);
|
||||
});
|
||||
|
||||
state.container.appendChild(scrubber);
|
||||
}
|
||||
|
||||
function updatePageScrubber(
|
||||
state: PageScrubberState,
|
||||
currentPage: number,
|
||||
): PageScrubberState {
|
||||
state.currentPage = currentPage;
|
||||
|
||||
const label = state.container.querySelector(".page-label");
|
||||
if (label) {
|
||||
label.textContent = String(currentPage);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function showPageScrubber(state: PageScrubberState): void {
|
||||
const scrubber = state.container.querySelector(".page-scrubber");
|
||||
scrubber?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hidePageScrubber(state: PageScrubberState): void {
|
||||
const scrubber = state.container.querySelector(".page-scrubber");
|
||||
scrubber?.classList.add("hidden");
|
||||
}
|
||||
|
||||
function dispatchPageNavigationEvent(page: number): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("navigate-to-page", { detail: { page } }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
// ML-based panel detection using COCO-SSD pre-trained model
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
let model: any = null;
|
||||
let tfLoaded = false;
|
||||
|
||||
async function loadTF(): Promise<void> {
|
||||
if (tfLoaded) return;
|
||||
|
||||
// Load TensorFlow.js
|
||||
await import("@tensorflow/tfjs");
|
||||
tfLoaded = true;
|
||||
}
|
||||
|
||||
async function loadModel(): Promise<void> {
|
||||
if (model) return;
|
||||
|
||||
await loadTF();
|
||||
|
||||
// Load COCO-SSD model (pre-trained on millions of images)
|
||||
const cocoSsd = await import("@tensorflow-models/coco-ssd");
|
||||
model = await cocoSsd.load({
|
||||
base: "lite_mobilenet_v2", // Smaller, faster model
|
||||
});
|
||||
}
|
||||
|
||||
async function detectPanelsML(imageData: ImageData): Promise<Panel[]> {
|
||||
await loadModel();
|
||||
|
||||
// Create HTMLCanvasElement to run model inference
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Run COCO-SSD model
|
||||
const predictions = await model.detect(canvas);
|
||||
|
||||
// Filter predictions to find rectangular regions (panels)
|
||||
// COCO-SSD detects common objects, we look for rectangular ones
|
||||
const panels: Panel[] = [];
|
||||
const imgWidth = imageData.width;
|
||||
const imgHeight = imageData.height;
|
||||
|
||||
for (let i = 0; i < predictions.length; i++) {
|
||||
const pred = predictions[i];
|
||||
|
||||
// COCO-SSD detects "book" and similar objects
|
||||
// We filter for reasonable panel-like detections
|
||||
const [x, y, w, h] = pred.bbox;
|
||||
const aspectRatio = w / h;
|
||||
|
||||
const isRectangular =
|
||||
aspectRatio > 0.3 && // Not too tall/thin
|
||||
aspectRatio < 5 && // Not too wide
|
||||
w > imgWidth * 0.05 && // Not too small
|
||||
h > imgHeight * 0.05;
|
||||
|
||||
if (isRectangular) {
|
||||
panels.push({
|
||||
id: `ml-panel-${i}`,
|
||||
x: (x / imgWidth) * 100,
|
||||
y: (y / imgHeight) * 100,
|
||||
width: (w / imgWidth) * 100,
|
||||
height: (h / imgHeight) * 100,
|
||||
reading_order: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort panels by reading order
|
||||
panels.sort((a, b) => {
|
||||
const rowA = Math.floor(a.y / 25);
|
||||
const rowB = Math.floor(b.y / 25);
|
||||
if (rowA !== rowB) return rowA - rowB;
|
||||
return a.x - b.x;
|
||||
});
|
||||
|
||||
panels.forEach((p, i) => (p.reading_order = i));
|
||||
|
||||
return panels;
|
||||
}
|
||||
|
||||
export { detectPanelsML, loadModel };
|
||||
@@ -1,113 +0,0 @@
|
||||
// OpenCV.js-based edge detection for panel boundaries
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
let openCVLoaded = false;
|
||||
|
||||
async function loadOpenCV(): Promise<void> {
|
||||
if (openCVLoaded) return;
|
||||
|
||||
// OpenCV.js loads asynchronously and registers globally
|
||||
await import("@techstark/opencv-js");
|
||||
|
||||
// Wait for OpenCV to be ready
|
||||
return new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
if ((window as any).cv && (window as any).cv.Mat) {
|
||||
openCVLoaded = true;
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(check, 50);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
async function detectPanelsOpenCV(imageData: ImageData): Promise<Panel[]> {
|
||||
await loadOpenCV();
|
||||
|
||||
const cv = (window as any).cv;
|
||||
|
||||
// Create matrices from ImageData
|
||||
const src = cv.matFromImageData(imageData);
|
||||
const gray = new cv.Mat();
|
||||
const blurred = new cv.Mat();
|
||||
const edges = new cv.Mat();
|
||||
const contours = new cv.Mat();
|
||||
const hierarchy = new cv.Mat();
|
||||
|
||||
try {
|
||||
// Convert to grayscale
|
||||
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0);
|
||||
|
||||
// Apply Gaussian blur to reduce noise
|
||||
cv.GaussianBlur(gray, blurred, new cv.Size(5, 5), 0, 0, cv.BORDER_DEFAULT);
|
||||
|
||||
// Detect edges using Canny
|
||||
cv.Canny(blurred, edges, 50, 150, 3, false);
|
||||
|
||||
// Find contours
|
||||
cv.findContours(
|
||||
edges,
|
||||
contours,
|
||||
hierarchy,
|
||||
cv.RETR_EXTERNAL,
|
||||
cv.CHAIN_APPROX_SIMPLE,
|
||||
);
|
||||
|
||||
// Convert contours to panels
|
||||
const panels: Panel[] = [];
|
||||
const imgWidth = imageData.width;
|
||||
const imgHeight = imageData.height;
|
||||
|
||||
for (let i = 0; i < contours.size(); i++) {
|
||||
const rect = cv.boundingRect(contours.get(i));
|
||||
const aspectRatio = rect.width / rect.height;
|
||||
|
||||
// Filter: reject very small or very thin contours
|
||||
const minSize = Math.min(imgWidth, imgHeight) * 0.05;
|
||||
if (rect.width < minSize || rect.height < minSize) continue;
|
||||
if (aspectRatio < 0.1 || aspectRatio > 10) continue;
|
||||
|
||||
panels.push({
|
||||
id: `opencv-panel-${i}`,
|
||||
x: (rect.x / imgWidth) * 100,
|
||||
y: (rect.y / imgHeight) * 100,
|
||||
width: (rect.width / imgWidth) * 100,
|
||||
height: (rect.height / imgHeight) * 100,
|
||||
reading_order: i,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort panels by reading order (top-left to bottom-right)
|
||||
panels.sort((a, b) => {
|
||||
const rowA = Math.floor(a.y / 25);
|
||||
const rowB = Math.floor(b.y / 25);
|
||||
if (rowA !== rowB) return rowA - rowB;
|
||||
return a.x - b.x;
|
||||
});
|
||||
|
||||
// Reassign reading order after sorting
|
||||
panels.forEach((p, i) => (p.reading_order = i));
|
||||
|
||||
return panels;
|
||||
} finally {
|
||||
// Clean up OpenCV matrices
|
||||
src.delete();
|
||||
gray.delete();
|
||||
blurred.delete();
|
||||
edges.delete();
|
||||
contours.delete();
|
||||
hierarchy.delete();
|
||||
}
|
||||
}
|
||||
|
||||
export { detectPanelsOpenCV, loadOpenCV };
|
||||
@@ -1,77 +0,0 @@
|
||||
// Main panel detection service with fallback chain
|
||||
// Priority: OpenCV → ML → Grid → Manual Editor
|
||||
import { detectPanelsOpenCV } from "./panel-detection.opencv";
|
||||
import { detectPanelsML } from "./panel-detection.ml";
|
||||
import { detectPanelsGrid } from "./panel-detector";
|
||||
|
||||
interface DetectionResult {
|
||||
panels: Panel[];
|
||||
method: "opencv" | "ml" | "grid" | "manual";
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
async function detectPanels(
|
||||
imageData: ImageData,
|
||||
allowManual: boolean = true,
|
||||
): Promise<DetectionResult> {
|
||||
// Tier 1: OpenCV Edge Detection
|
||||
try {
|
||||
const panels = await detectPanelsOpenCV(imageData);
|
||||
if (validatePanels(panels, imageData)) {
|
||||
return { panels, method: "opencv", confidence: 0.85 };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("OpenCV detection failed:", e);
|
||||
}
|
||||
|
||||
// Tier 2: ML Detection (COCO-SSD)
|
||||
try {
|
||||
const panels = await detectPanelsML(imageData);
|
||||
if (validatePanels(panels, imageData)) {
|
||||
return { panels, method: "ml", confidence: 0.9 };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("ML detection failed:", e);
|
||||
}
|
||||
|
||||
// Tier 3: Grid Detection (baseline)
|
||||
const panels = detectPanelsGrid(imageData);
|
||||
if (allowManual && panels.length === 0) {
|
||||
return {
|
||||
panels: [],
|
||||
method: "manual" as const,
|
||||
confidence: 0,
|
||||
};
|
||||
}
|
||||
return { panels, method: "grid", confidence: 0.5 };
|
||||
}
|
||||
|
||||
function validatePanels(panels: Panel[], imageData: ImageData): boolean {
|
||||
// Must have at least 1 panel
|
||||
if (panels.length === 0) return false;
|
||||
// Should not have too many panels (probably noise)
|
||||
if (panels.length > 30) return false;
|
||||
// Panels should cover reasonable area (not all empty space)
|
||||
let totalArea = panels.reduce((sum, p) => sum + p.width * p.height, 0);
|
||||
if (totalArea < 10 || totalArea > 100) return false;
|
||||
// Check panel sizes are reasonable relative to image dimensions
|
||||
const minPanelSize = Math.min(imageData.width, imageData.height) * 0.02;
|
||||
const tooSmall = panels.some(
|
||||
(p) =>
|
||||
(p.width / 100) * imageData.width < minPanelSize ||
|
||||
(p.height / 100) * imageData.height < minPanelSize,
|
||||
);
|
||||
if (tooSmall) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export { detectPanels, DetectionResult, Panel };
|
||||
@@ -1,172 +0,0 @@
|
||||
// Grid-based panel detection (fast, lightweight)
|
||||
// Keep as final fallback
|
||||
|
||||
export interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
interface GridConfig {
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
function detectPanelsGrid(
|
||||
imageData: ImageData,
|
||||
config: GridConfig = { rows: 3, cols: 3 },
|
||||
): Panel[] {
|
||||
const panels: Panel[] = [];
|
||||
const cellWidth = imageData.width / config.cols;
|
||||
const cellHeight = imageData.height / config.rows;
|
||||
|
||||
for (let y = 0; y < config.rows; y++) {
|
||||
for (let x = 0; x < config.cols; x++) {
|
||||
const cell = extractCell(imageData, x, y, cellWidth, cellHeight);
|
||||
|
||||
if (!isEmpty(cell)) {
|
||||
panels.push({
|
||||
id: `panel-${panels.length}`,
|
||||
x: (x / config.cols) * 100,
|
||||
y: (y / config.rows) * 100,
|
||||
width: (1 / config.cols) * 100,
|
||||
height: (1 / config.rows) * 100,
|
||||
reading_order: panels.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergeAdjacentPanels(panels);
|
||||
}
|
||||
|
||||
function isEmpty(cellData: ImageData): boolean {
|
||||
// Simple edge detection to find empty space
|
||||
// Count white/transparent pixels
|
||||
let emptyPixels = 0;
|
||||
const totalPixels = cellData.width * cellData.height;
|
||||
const threshold = 0.95; // 95% empty = empty cell
|
||||
|
||||
for (let i = 0; i < cellData.data.length; i += 4) {
|
||||
const r = cellData.data[i];
|
||||
const g = cellData.data[i + 1];
|
||||
const b = cellData.data[i + 2];
|
||||
const a = cellData.data[i + 3];
|
||||
|
||||
// Consider white or transparent as empty
|
||||
if (a < 10 || (r > 250 && g > 250 && b > 250)) {
|
||||
emptyPixels++;
|
||||
}
|
||||
}
|
||||
|
||||
return emptyPixels / totalPixels > threshold;
|
||||
}
|
||||
|
||||
function mergeAdjacentPanels(panels: Panel[]): Panel[] {
|
||||
// Merge panels that are next to each other
|
||||
// Simplified algorithm - can be enhanced
|
||||
const merged: Panel[] = [];
|
||||
const used = new Set<number>();
|
||||
|
||||
for (let i = 0; i < panels.length; i++) {
|
||||
if (used.has(i)) continue;
|
||||
|
||||
let current = { ...panels[i] };
|
||||
used.add(i);
|
||||
|
||||
// Look for adjacent panels
|
||||
for (let j = i + 1; j < panels.length; j++) {
|
||||
if (used.has(j)) continue;
|
||||
if (isAdjacent(current, panels[j])) {
|
||||
current = mergePanels(current, panels[j]);
|
||||
used.add(j);
|
||||
}
|
||||
}
|
||||
|
||||
merged.push(current);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function extractCell(
|
||||
imageData: ImageData,
|
||||
gridX: number,
|
||||
gridY: number,
|
||||
cellWidth: number,
|
||||
cellHeight: number,
|
||||
): ImageData {
|
||||
const startX = Math.floor(gridX * cellWidth);
|
||||
const startY = Math.floor(gridY * cellHeight);
|
||||
const width = Math.floor(cellWidth);
|
||||
const height = Math.floor(cellHeight);
|
||||
|
||||
const cellData = new Uint8ClampedArray(width * height * 4);
|
||||
// Copy pixels for the cell region
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIdx = ((startY + y) * imageData.width + (startX + x)) * 4;
|
||||
const destIdx = (y * width + x) * 4;
|
||||
cellData[destIdx] = imageData.data[srcIdx];
|
||||
cellData[destIdx + 1] = imageData.data[srcIdx + 1];
|
||||
cellData[destIdx + 2] = imageData.data[srcIdx + 2];
|
||||
cellData[destIdx + 3] = imageData.data[srcIdx + 3];
|
||||
}
|
||||
}
|
||||
|
||||
return new ImageData(cellData, width, height);
|
||||
}
|
||||
|
||||
function isAdjacent(p1: Panel, p2: Panel): boolean {
|
||||
const tolerance = 5; // 5% tolerance for alignment
|
||||
// Check horizontal adjacency
|
||||
if (
|
||||
Math.abs(p1.y - p2.y) < tolerance &&
|
||||
Math.abs(p1.height - p2.height) < tolerance
|
||||
) {
|
||||
return (
|
||||
Math.abs(p1.x + p1.width - p2.x) < tolerance ||
|
||||
Math.abs(p2.x + p2.width - p1.x) < tolerance
|
||||
);
|
||||
}
|
||||
// Check vertical adjacency
|
||||
if (
|
||||
Math.abs(p1.x - p2.x) < tolerance &&
|
||||
Math.abs(p1.width - p2.width) < tolerance
|
||||
) {
|
||||
return (
|
||||
Math.abs(p1.y + p1.height - p2.y) < tolerance ||
|
||||
Math.abs(p2.y + p2.height - p1.y) < tolerance
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function mergePanels(p1: Panel, p2: Panel): Panel {
|
||||
const minX = Math.min(p1.x, p2.x);
|
||||
const minY = Math.min(p1.y, p2.y);
|
||||
const maxX = Math.max(p1.x + p1.width, p2.x + p2.width);
|
||||
const maxY = Math.max(p1.y + p1.height, p2.y + p2.height);
|
||||
|
||||
return {
|
||||
id: p1.id,
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
reading_order: Math.min(p1.reading_order, p2.reading_order),
|
||||
};
|
||||
}
|
||||
|
||||
// ADD THIS EXPORT AT THE END OF THE FILE
|
||||
export {
|
||||
detectPanelsGrid,
|
||||
isEmpty,
|
||||
mergeAdjacentPanels,
|
||||
extractCell,
|
||||
isAdjacent,
|
||||
mergePanels,
|
||||
};
|
||||
@@ -1,168 +0,0 @@
|
||||
// Manual panel editor for admins/power users
|
||||
|
||||
import { Alpine } from "../../../alpine";
|
||||
import { apiPut } from "../../../api";
|
||||
import { Panel } from "./panel-detector";
|
||||
import { detectPanels } from "./panel-detection.service";
|
||||
|
||||
async function loadImageForPage(pageNumber: number): Promise<HTMLImageElement> {
|
||||
const mediaItemId = document.body.dataset.mediaItemId;
|
||||
if (!mediaItemId) {
|
||||
throw new Error("No mediaItemId found");
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
const response = await fetch(`/readers/${mediaItemId}/pages/${pageNumber}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load page ${pageNumber}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(blob);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
img.onload = () => resolve();
|
||||
});
|
||||
|
||||
return img;
|
||||
}
|
||||
|
||||
function getCurrentPageNumber(): number {
|
||||
// Try Alpine first
|
||||
const Alpine = (window as any).Alpine;
|
||||
if (Alpine) {
|
||||
const readerEl = document.querySelector('[x-data="readerShell"]');
|
||||
if (readerEl) {
|
||||
const readerShell = Alpine.$data(readerEl);
|
||||
if (readerShell?.currentPage) {
|
||||
return readerShell.currentPage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check for dataset attribute on reader content
|
||||
const content = document.getElementById("reader-content");
|
||||
const pageFromDataset = content?.dataset.currentPage;
|
||||
if (pageFromDataset) {
|
||||
return parseInt(pageFromDataset, 10);
|
||||
}
|
||||
|
||||
// Final fallback
|
||||
return 1;
|
||||
}
|
||||
function loadPage(pageNumber: number): void {
|
||||
// Dispatch event for reader to handle navigation
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("navigate-to-page", { detail: { page: pageNumber } }),
|
||||
);
|
||||
}
|
||||
|
||||
function openPanelEditor(pageNumber: number): void {
|
||||
const modal = document.getElementById("panel-editor-modal");
|
||||
modal?.classList.remove("hidden");
|
||||
|
||||
// Load page image
|
||||
const canvas = document.getElementById(
|
||||
"panel-editor-canvas",
|
||||
) as HTMLCanvasElement;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
|
||||
// Load image and draw to canvas
|
||||
loadImageForPage(pageNumber).then((image) => {
|
||||
canvas!.width = image.width;
|
||||
canvas!.height = image.height;
|
||||
ctx?.drawImage(image, 0, 0);
|
||||
|
||||
// Allow user to draw panels
|
||||
enablePanelDrawing(canvas!);
|
||||
});
|
||||
}
|
||||
|
||||
function enablePanelDrawing(canvas: HTMLCanvasElement): void {
|
||||
let isDrawing = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
canvas.addEventListener("mousedown", (e) => {
|
||||
isDrawing = true;
|
||||
startX = e.offsetX;
|
||||
startY = e.offsetY;
|
||||
});
|
||||
|
||||
canvas.addEventListener("mousemove", (e) => {
|
||||
if (!isDrawing) return;
|
||||
|
||||
// Draw selection rectangle
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.strokeRect(startX, startY, e.offsetX - startX, e.offsetY - startY);
|
||||
});
|
||||
|
||||
canvas.addEventListener("mouseup", (e) => {
|
||||
if (!isDrawing) return;
|
||||
isDrawing = false;
|
||||
|
||||
// Save panel
|
||||
const panel: Panel = {
|
||||
id: `manual-${Date.now()}`,
|
||||
x: (startX / canvas.width) * 100,
|
||||
y: (startY / canvas.height) * 100,
|
||||
width: ((e.offsetX - startX) / canvas.width) * 100,
|
||||
height: ((e.offsetY - startY) / canvas.height) * 100,
|
||||
reading_order: 0, // Will be set by server
|
||||
};
|
||||
|
||||
saveManualPanel(panel);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveManualPanel(panel: Panel): Promise<void> {
|
||||
const mediaItemId = document.body.dataset.mediaItemId;
|
||||
const pageNumber = getCurrentPageNumber();
|
||||
|
||||
await apiPut(`/readers/${mediaItemId}/panels/${pageNumber}`, {
|
||||
detection_method: "manual",
|
||||
panels: [panel],
|
||||
});
|
||||
|
||||
// Reload with new panels
|
||||
loadPage(pageNumber);
|
||||
}
|
||||
|
||||
// Re-detect panels using detection service
|
||||
async function reDetectPanels(pageNumber: number): Promise<Panel[]> {
|
||||
const image = await loadImageForPage(pageNumber);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const result = await detectPanels(imageData, true);
|
||||
|
||||
return result.panels;
|
||||
}
|
||||
|
||||
// Alpine component
|
||||
Alpine.data("panelEditor", () => ({
|
||||
get isComicOrManga(): boolean {
|
||||
const libraryType = document.body.dataset.mediaType;
|
||||
return libraryType === "comic" || libraryType === "manga";
|
||||
},
|
||||
|
||||
openPanelEditor(pageNumber: number) {
|
||||
openPanelEditor(pageNumber);
|
||||
},
|
||||
|
||||
async reDetectPanels(pageNumber: number) {
|
||||
const panels = await reDetectPanels(pageNumber);
|
||||
return panels;
|
||||
},
|
||||
}));
|
||||
|
||||
export { openPanelEditor, reDetectPanels };
|
||||
@@ -1,160 +0,0 @@
|
||||
// Adjustable panel gap controls
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import { 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;
|
||||
showBorders: boolean;
|
||||
}
|
||||
|
||||
function createPanelGapState(initialGap: number = 4): PanelGapState {
|
||||
const saved = localStorage.getItem("reader-panel-gap");
|
||||
return {
|
||||
gapSize: saved ? parseInt(saved) : initialGap,
|
||||
showBorders: false,
|
||||
};
|
||||
}
|
||||
|
||||
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 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 {
|
||||
return setPanelGap(state, state.gapSize - amount);
|
||||
}
|
||||
|
||||
function togglePanelBorders(state: PanelGapState): PanelGapState {
|
||||
state.showBorders = !state.showBorders;
|
||||
|
||||
document.documentElement.style.setProperty(
|
||||
"--panel-border-width",
|
||||
state.showBorders ? "1px" : "0px",
|
||||
);
|
||||
|
||||
const controls = document.querySelector(".panel-gap-controls");
|
||||
if (controls) {
|
||||
updatePanelGapUI(controls as HTMLElement, state);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function renderPanelGapControls(
|
||||
container: HTMLElement,
|
||||
state: PanelGapState,
|
||||
): void {
|
||||
const existing = container.querySelector(".panel-gap-controls");
|
||||
existing?.remove();
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className =
|
||||
"panel-gap-controls fixed bottom-24 right-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex flex-col gap-2 z-40";
|
||||
controls.innerHTML = `
|
||||
<button class="panel-gap-increase p-2 hover:bg-gray-700 rounded" title="Increase gap">+</button>
|
||||
<span class="text-center text-sm">${state.gapSize}px</span>
|
||||
<button class="panel-gap-decrease p-2 hover:bg-gray-700 rounded" title="Decrease gap">-</button>
|
||||
<button class="panel-gap-borders p-2 hover:bg-gray-700 rounded" title="Toggle borders">
|
||||
${state.showBorders ? "▦" : "▢"}
|
||||
</button>
|
||||
`;
|
||||
|
||||
controls
|
||||
.querySelector(".panel-gap-increase")
|
||||
?.addEventListener("click", () => {
|
||||
increasePanelGap(state);
|
||||
updatePanelGapUI(controls, state);
|
||||
});
|
||||
|
||||
controls
|
||||
.querySelector(".panel-gap-decrease")
|
||||
?.addEventListener("click", () => {
|
||||
decreasePanelGap(state);
|
||||
updatePanelGapUI(controls, state);
|
||||
});
|
||||
|
||||
controls
|
||||
.querySelector(".panel-gap-borders")
|
||||
?.addEventListener("click", () => {
|
||||
togglePanelBorders(state);
|
||||
updatePanelGapUI(controls, state);
|
||||
});
|
||||
|
||||
container.appendChild(controls);
|
||||
}
|
||||
|
||||
function updatePanelGapUI(container: HTMLElement, state: PanelGapState): void {
|
||||
const gapLabel = container.querySelector("span");
|
||||
if (gapLabel) {
|
||||
gapLabel.textContent = `${state.gapSize}px`;
|
||||
}
|
||||
|
||||
const bordersBtn = container.querySelector(".panel-gap-borders");
|
||||
if (bordersBtn) {
|
||||
bordersBtn.textContent = state.showBorders ? "▦" : "▢";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
// Detect reading direction from metadata or user preference
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import { ReaderContext } from "../../core/reader-context";
|
||||
|
||||
interface MangaMetadata {
|
||||
manga_type?: string;
|
||||
reading_direction?: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
interface ReadingDirectionState {
|
||||
direction: ReadingDirection;
|
||||
detectedDirection: "ltr" | "rtl" | "vertical";
|
||||
userPreference: ReadingDirection | null;
|
||||
}
|
||||
|
||||
async function detectReadingDirection(
|
||||
metadata: any,
|
||||
): Promise<ReadingDirectionState> {
|
||||
const userPreference = await getUserReadingDirectionPreference();
|
||||
if (userPreference && userPreference !== "auto") {
|
||||
return {
|
||||
direction: userPreference,
|
||||
detectedDirection: "ltr",
|
||||
userPreference,
|
||||
};
|
||||
}
|
||||
|
||||
const detectedDirection = detectFromMetadata(metadata);
|
||||
|
||||
return {
|
||||
direction: "auto",
|
||||
detectedDirection,
|
||||
userPreference: null,
|
||||
};
|
||||
}
|
||||
|
||||
function detectFromMetadata(
|
||||
metadata: MangaMetadata,
|
||||
): "ltr" | "rtl" | "vertical" {
|
||||
const mangaType = metadata.manga_type;
|
||||
if (mangaType === "yes_and_right_to_left" || mangaType === "yes") {
|
||||
return "rtl";
|
||||
}
|
||||
|
||||
const readingDirection = metadata.reading_direction;
|
||||
if (readingDirection === "rtl" || readingDirection === "vertical") {
|
||||
return readingDirection;
|
||||
}
|
||||
|
||||
const filename = metadata.filePath.toLowerCase();
|
||||
if (
|
||||
filename.includes("manga") ||
|
||||
filename.includes("manhwa") ||
|
||||
filename.includes("webtoon")
|
||||
) {
|
||||
return "vertical";
|
||||
}
|
||||
|
||||
return "ltr";
|
||||
}
|
||||
|
||||
async function getUserReadingDirectionPreference(): Promise<ReadingDirection | null> {
|
||||
const userId = localStorage.getItem("userId");
|
||||
if (!userId) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}/settings`);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const settings = await response.json();
|
||||
return settings.reading_direction || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getEffectiveDirection(
|
||||
state: ReadingDirectionState,
|
||||
): "ltr" | "rtl" | "vertical" {
|
||||
if (state.direction !== "auto") {
|
||||
return state.direction as "ltr" | "rtl" | "vertical";
|
||||
}
|
||||
return state.detectedDirection;
|
||||
}
|
||||
|
||||
function shouldUseRTL(state: ReadingDirectionState): boolean {
|
||||
return getEffectiveDirection(state) === "rtl";
|
||||
}
|
||||
|
||||
function shouldUseVerticalScroll(state: ReadingDirectionState): boolean {
|
||||
return getEffectiveDirection(state) === "vertical";
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
// Right-to-left navigation for manga
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import { 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;
|
||||
totalPages: number;
|
||||
readingDirection: "rtl" | "ltr";
|
||||
}
|
||||
|
||||
function createRTLNavigator(totalPages: number): RTLNavigatorState {
|
||||
return {
|
||||
currentPage: 1,
|
||||
totalPages,
|
||||
readingDirection: "rtl",
|
||||
};
|
||||
}
|
||||
|
||||
function getNextPage(state: RTLNavigatorState): number {
|
||||
if (state.readingDirection === "rtl") {
|
||||
return Math.max(1, state.currentPage - 1);
|
||||
}
|
||||
return Math.min(state.totalPages, state.currentPage + 1);
|
||||
}
|
||||
|
||||
function getPreviousPage(state: RTLNavigatorState): number {
|
||||
if (state.readingDirection === "rtl") {
|
||||
return Math.min(state.totalPages, state.currentPage + 1);
|
||||
}
|
||||
return Math.max(1, state.currentPage - 1);
|
||||
}
|
||||
|
||||
function navigateToPage(
|
||||
state: RTLNavigatorState,
|
||||
pageNumber: number,
|
||||
): RTLNavigatorState {
|
||||
state.currentPage = Math.max(1, Math.min(state.totalPages, pageNumber));
|
||||
return state;
|
||||
}
|
||||
|
||||
function getProgress(state: RTLNavigatorState): {
|
||||
current: number;
|
||||
total: number;
|
||||
} {
|
||||
return {
|
||||
current: state.currentPage,
|
||||
total: state.totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
function getReadingProgressPercentage(state: RTLNavigatorState): number {
|
||||
return (state.currentPage / state.totalPages) * 100;
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
// Manga-specific settings integration
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import { 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";
|
||||
verticalScrollSpeed: "slow" | "normal" | "fast";
|
||||
rtlPageTransition: "slide" | "fade" | "none";
|
||||
webtoonMode: boolean;
|
||||
}
|
||||
|
||||
async function getMangaSettings(): Promise<MangaSettings> {
|
||||
const defaultSettings: MangaSettings = {
|
||||
readingDirection: "auto",
|
||||
verticalScrollSpeed: "normal",
|
||||
rtlPageTransition: "slide",
|
||||
webtoonMode: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const userId = localStorage.getItem("userId");
|
||||
const response = await fetch(`/api/users/${userId}/settings`);
|
||||
|
||||
if (response.ok) {
|
||||
const settings = await response.json();
|
||||
return { ...defaultSettings, ...settings };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load manga settings:", error);
|
||||
}
|
||||
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
async function updateMangaSettings(
|
||||
settings: Partial<MangaSettings>,
|
||||
): Promise<void> {
|
||||
const userId = localStorage.getItem("userId");
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}/settings`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to update manga settings");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save manga settings:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function applyMangaSettings(settings: MangaSettings): void {
|
||||
document.documentElement.dataset.readingDirection = settings.readingDirection;
|
||||
|
||||
if (settings.verticalScrollSpeed === "slow") {
|
||||
document.documentElement.style.scrollBehavior = "smooth";
|
||||
} else if (settings.verticalScrollSpeed === "fast") {
|
||||
document.documentElement.style.scrollBehavior = "auto";
|
||||
}
|
||||
|
||||
if (settings.rtlPageTransition !== "none") {
|
||||
document.documentElement.dataset.pageTransition =
|
||||
settings.rtlPageTransition;
|
||||
}
|
||||
|
||||
document.documentElement.dataset.webtoonMode = String(settings.webtoonMode);
|
||||
}
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
// Vertical scroll mode for webtoons/manhwa
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import { 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;
|
||||
loadedPages: Set<number>;
|
||||
loadingPages: Set<number>;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
threshold: number;
|
||||
mediaItemId: string;
|
||||
}
|
||||
|
||||
function createVerticalScroll(
|
||||
container: HTMLElement,
|
||||
mediaItemId: string,
|
||||
totalPages: number,
|
||||
): VerticalScrollState {
|
||||
const state: VerticalScrollState = {
|
||||
container,
|
||||
loadedPages: new Set(),
|
||||
loadingPages: new Set(),
|
||||
currentPage: 1,
|
||||
totalPages,
|
||||
threshold: 500,
|
||||
mediaItemId,
|
||||
};
|
||||
|
||||
loadPage(state, 1);
|
||||
setupScrollListener(state);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async function loadPage(
|
||||
state: VerticalScrollState,
|
||||
pageNumber: number,
|
||||
): Promise<void> {
|
||||
if (state.loadedPages.has(pageNumber) || state.loadingPages.has(pageNumber)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.loadingPages.add(pageNumber);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
const response = await fetch(
|
||||
`/readers/${state.mediaItemId}/pages/${pageNumber}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load page ${pageNumber}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const imgUrl = URL.createObjectURL(blob);
|
||||
|
||||
const pageContainer = document.createElement("div");
|
||||
pageContainer.className = "vertical-page";
|
||||
pageContainer.dataset.pageNumber = pageNumber.toString();
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = imgUrl;
|
||||
img.alt = `Page ${pageNumber}`;
|
||||
img.loading = "lazy";
|
||||
|
||||
pageContainer.appendChild(img);
|
||||
state.container.appendChild(pageContainer);
|
||||
|
||||
state.loadedPages.add(pageNumber);
|
||||
state.loadingPages.delete(pageNumber);
|
||||
|
||||
if (pageNumber < state.totalPages) {
|
||||
loadPage(state, pageNumber + 1);
|
||||
if (pageNumber + 1 < state.totalPages) {
|
||||
loadPage(state, pageNumber + 2);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load page ${pageNumber}:`, error);
|
||||
state.loadingPages.delete(pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
function setupScrollListener(state: VerticalScrollState): void {
|
||||
let scrollTimeout: number | undefined;
|
||||
|
||||
state.container.addEventListener("scroll", () => {
|
||||
clearTimeout(scrollTimeout);
|
||||
scrollTimeout = window.setTimeout(() => {
|
||||
checkScrollPosition(state);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
function checkScrollPosition(state: VerticalScrollState): void {
|
||||
const scrollBottom =
|
||||
state.container.scrollHeight -
|
||||
state.container.scrollTop -
|
||||
state.container.clientHeight;
|
||||
|
||||
if (scrollBottom < state.threshold) {
|
||||
const lastPage = Math.max(...state.loadedPages);
|
||||
if (lastPage < state.totalPages) {
|
||||
loadPage(state, lastPage + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const currentPage = getCurrentPageFromScroll(state);
|
||||
if (currentPage !== state.currentPage) {
|
||||
state.currentPage = currentPage;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("page-change", {
|
||||
detail: { page: currentPage },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentPageFromScroll(state: VerticalScrollState): number {
|
||||
const pages = Array.from(state.container.querySelectorAll(".vertical-page"));
|
||||
|
||||
for (const page of pages) {
|
||||
const rect = page.getBoundingClientRect();
|
||||
const containerRect = state.container.getBoundingClientRect();
|
||||
|
||||
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 as HTMLElement).dataset.pageNumber || "1");
|
||||
}
|
||||
}
|
||||
|
||||
return state.currentPage;
|
||||
}
|
||||
|
||||
function destroyVerticalScroll(state: VerticalScrollState): void {
|
||||
const images = Array.from(state.container.querySelectorAll("img"));
|
||||
images.forEach((img) => {
|
||||
const url = img.src;
|
||||
if (url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
});
|
||||
|
||||
state.container.innerHTML = "";
|
||||
state.loadedPages.clear();
|
||||
state.loadingPages.clear();
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
// Annotation layer for rendering highlights and notes on PDFs
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import { 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;
|
||||
pageNumber: number;
|
||||
rects: DOMRect[];
|
||||
text: string;
|
||||
color: string;
|
||||
noteId?: string;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
for (const rect of highlight.rects) {
|
||||
const rectDiv = document.createElement("div");
|
||||
rectDiv.className = "pdf-highlight-rect";
|
||||
rectDiv.style.left = `${rect.left}px`;
|
||||
rectDiv.style.top = `${rect.top}px`;
|
||||
rectDiv.style.width = `${rect.width}px`;
|
||||
rectDiv.style.height = `${rect.height}px`;
|
||||
|
||||
overlay.appendChild(rectDiv);
|
||||
}
|
||||
|
||||
if (highlight.noteId) {
|
||||
overlay.style.cursor = "pointer";
|
||||
overlay.addEventListener("click", () => {
|
||||
showNotePopup(highlight);
|
||||
});
|
||||
}
|
||||
|
||||
overlay.addEventListener("mouseenter", () => {
|
||||
overlay.style.opacity = "0.8";
|
||||
});
|
||||
|
||||
overlay.addEventListener("mouseleave", () => {
|
||||
overlay.style.opacity = "0.5";
|
||||
});
|
||||
|
||||
container.appendChild(overlay);
|
||||
highlights.set(highlight.id, overlay);
|
||||
}
|
||||
|
||||
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 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 {
|
||||
const highlights = container.querySelectorAll(".pdf-highlight-annotation");
|
||||
Array.from(highlights).forEach((element) => element.remove());
|
||||
}
|
||||
|
||||
export function removePDFHighlight(
|
||||
highlightId: string,
|
||||
highlights: Map<string, HTMLElement>,
|
||||
): void {
|
||||
const element = highlights.get(highlightId);
|
||||
if (element) {
|
||||
element.remove();
|
||||
highlights.delete(highlightId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
// Custom bookmarks for PDF pages (saved in database)
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface MediaBookmark {
|
||||
id: string;
|
||||
mediaItemId: string;
|
||||
userId: string;
|
||||
pageNumber: number;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface MediaBookmarksState {
|
||||
mediaItemId: string;
|
||||
bookmarks: MediaBookmark[];
|
||||
}
|
||||
|
||||
function createMediaBookmarks(mediaItemId: string): MediaBookmarksState {
|
||||
return {
|
||||
mediaItemId,
|
||||
bookmarks: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function loadMediaBookmarks(
|
||||
state: MediaBookmarksState,
|
||||
): Promise<MediaBookmarksState> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/media-items/${state.mediaItemId}/bookmarks`,
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to load bookmarks");
|
||||
|
||||
const data = await response.json();
|
||||
return { ...state, bookmarks: data.bookmarks || [] };
|
||||
} catch (error) {
|
||||
console.error("Failed to load bookmarks:", error);
|
||||
return { ...state, bookmarks: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function addMediaBookmark(
|
||||
state: MediaBookmarksState,
|
||||
pageNumber: number,
|
||||
title?: string,
|
||||
): Promise<MediaBookmarksState & { bookmark: MediaBookmark }> {
|
||||
const bookmark: MediaBookmark = {
|
||||
id: crypto.randomUUID(),
|
||||
mediaItemId: state.mediaItemId,
|
||||
userId: "",
|
||||
pageNumber,
|
||||
title: title || `Page ${pageNumber}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/media-items/${state.mediaItemId}/bookmarks`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
page_number: pageNumber,
|
||||
title: bookmark.title,
|
||||
position: `pdf:page:${pageNumber}`,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error("Failed to create bookmark");
|
||||
|
||||
const created = await response.json();
|
||||
|
||||
return {
|
||||
...state,
|
||||
bookmarks: [...state.bookmarks, created],
|
||||
bookmark: created,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to add bookmark:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMediaBookmark(
|
||||
state: MediaBookmarksState,
|
||||
bookmarkId: string,
|
||||
): Promise<MediaBookmarksState> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/media-items/${state.mediaItemId}/bookmarks/${bookmarkId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error("Failed to remove bookmark");
|
||||
|
||||
return {
|
||||
...state,
|
||||
bookmarks: state.bookmarks.filter((b) => b.id !== bookmarkId),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to remove bookmark:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getMediaBookmarks(state: MediaBookmarksState): MediaBookmark[] {
|
||||
return [...state.bookmarks].sort((a, b) => a.pageNumber - b.pageNumber);
|
||||
}
|
||||
|
||||
function hasMediaBookmarkAt(
|
||||
state: MediaBookmarksState,
|
||||
pageNumber: number,
|
||||
): boolean {
|
||||
return state.bookmarks.some((b) => b.pageNumber === pageNumber);
|
||||
}
|
||||
|
||||
function getMediaBookmarkAt(
|
||||
state: MediaBookmarksState,
|
||||
pageNumber: number,
|
||||
): MediaBookmark | null {
|
||||
return state.bookmarks.find((b) => b.pageNumber === pageNumber) || null;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
// Copy selected text to clipboard (plain text, preserve line breaks)
|
||||
// Critical for technical textbooks with code examples
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
function setupPDFClipboard(container: HTMLElement): void {
|
||||
container.addEventListener("copy", (e) => {
|
||||
handlePDFCopy(e);
|
||||
});
|
||||
}
|
||||
|
||||
function handlePDFCopy(event: ClipboardEvent): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
const selectedText = selection.toString();
|
||||
|
||||
if (!selectedText) return;
|
||||
|
||||
const plainText = formatPDFPlainText(selectedText);
|
||||
|
||||
event.clipboardData?.setData("text/plain", plainText);
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
showPDFCopyFeedback();
|
||||
}
|
||||
|
||||
function formatPDFPlainText(text: string): string {
|
||||
let formatted = text;
|
||||
|
||||
formatted = formatted.replace(/[ \t]+/g, " ");
|
||||
|
||||
formatted = formatted
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.join("\n");
|
||||
|
||||
formatted = formatted.replace(/\n{3,}/g, "\n\n");
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
async function copyPDFText(text: string): Promise<boolean> {
|
||||
const formatted = formatPDFPlainText(text);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(formatted);
|
||||
showPDFCopyFeedback();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to copy text:", error);
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = formatted;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
const success = document.execCommand("copy");
|
||||
if (success) {
|
||||
showPDFCopyFeedback();
|
||||
}
|
||||
return success;
|
||||
} catch (fallbackError) {
|
||||
console.error("Fallback copy failed:", fallbackError);
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showPDFCopyFeedback(): void {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "pdf-copy-toast";
|
||||
toast.textContent = "Copied to clipboard";
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
z-index: 10000;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = "fadeOut 0.2s ease-out";
|
||||
setTimeout(() => toast.remove(), 200);
|
||||
}, 1500);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
// Dual page spread view for PDFs
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
type DualPageMode = "single" | "dual";
|
||||
|
||||
interface PDFDualPageViewState {
|
||||
currentMode: DualPageMode;
|
||||
minViewportWidth: number;
|
||||
}
|
||||
|
||||
function createPDFDualPageView(
|
||||
container: HTMLElement,
|
||||
onModeChange: (mode: DualPageMode) => void,
|
||||
): PDFDualPageViewState {
|
||||
const state: PDFDualPageViewState = {
|
||||
currentMode: "single",
|
||||
minViewportWidth: 1200,
|
||||
};
|
||||
|
||||
setupResponsiveDualPageToggle(container, state, onModeChange);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function setupResponsiveDualPageToggle(
|
||||
container: HTMLElement,
|
||||
state: PDFDualPageViewState,
|
||||
onModeChange: (mode: DualPageMode) => void,
|
||||
): void {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
handleDualPageResize(container, state, onModeChange);
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
}
|
||||
|
||||
function handleDualPageResize(
|
||||
container: HTMLElement,
|
||||
state: PDFDualPageViewState,
|
||||
onModeChange: (mode: DualPageMode) => void,
|
||||
): PDFDualPageViewState {
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
if (
|
||||
viewportWidth >= state.minViewportWidth &&
|
||||
state.currentMode === "single"
|
||||
) {
|
||||
if (!hasManualDualPageOverride()) {
|
||||
return setDualPageMode(container, state, "dual", false, onModeChange);
|
||||
}
|
||||
} else if (
|
||||
viewportWidth < state.minViewportWidth &&
|
||||
state.currentMode === "dual"
|
||||
) {
|
||||
return setDualPageMode(container, state, "single", false, onModeChange);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function setDualPageMode(
|
||||
container: HTMLElement,
|
||||
state: PDFDualPageViewState,
|
||||
mode: DualPageMode,
|
||||
manual: boolean,
|
||||
onModeChange: (mode: DualPageMode) => void,
|
||||
): PDFDualPageViewState {
|
||||
if (state.currentMode === mode) return state;
|
||||
|
||||
container.classList.remove("pdf-single-page", "pdf-dual-page");
|
||||
container.classList.add(
|
||||
mode === "dual" ? "pdf-dual-page" : "pdf-single-page",
|
||||
);
|
||||
|
||||
if (manual) {
|
||||
setManualDualPageOverride(mode);
|
||||
}
|
||||
|
||||
onModeChange(mode);
|
||||
|
||||
return { ...state, currentMode: mode };
|
||||
}
|
||||
|
||||
function toggleDualPageMode(
|
||||
container: HTMLElement,
|
||||
state: PDFDualPageViewState,
|
||||
onModeChange: (mode: DualPageMode) => void,
|
||||
): PDFDualPageViewState {
|
||||
const newMode = state.currentMode === "single" ? "dual" : "single";
|
||||
return setDualPageMode(container, state, newMode, true, onModeChange);
|
||||
}
|
||||
|
||||
function getDualPagePagePair(
|
||||
state: PDFDualPageViewState,
|
||||
currentPage: number,
|
||||
totalPages: number,
|
||||
): { left?: number; right: number } {
|
||||
if (state.currentMode === "single") {
|
||||
return { right: currentPage };
|
||||
}
|
||||
|
||||
if (currentPage % 2 === 1) {
|
||||
return {
|
||||
left: currentPage > 1 ? currentPage - 1 : undefined,
|
||||
right: currentPage,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
left: currentPage,
|
||||
right: currentPage < totalPages ? currentPage + 1 : currentPage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function hasManualDualPageOverride(): boolean {
|
||||
return localStorage.getItem("pdf-dual-page-manual") === "true";
|
||||
}
|
||||
|
||||
function setManualDualPageOverride(mode: DualPageMode): void {
|
||||
localStorage.setItem("pdf-dual-page-manual", "true");
|
||||
localStorage.setItem("pdf-dual-page-mode", mode);
|
||||
}
|
||||
|
||||
function getDualPageStyles(): string {
|
||||
return `
|
||||
.pdf-dual-page .pdf-page-container {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.pdf-dual-page .pdf-scroll-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pdf-single-page .pdf-page-container {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
// Handle internal PDF links (cross-references, citations, TOC links)
|
||||
// External links open in new tab
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
import { PDFDocumentProxy } from "pdfjs-dist";
|
||||
|
||||
interface PDFLink {
|
||||
url: string;
|
||||
pageNumber?: number;
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
interface PDFLinkHandlerState {
|
||||
doc: PDFDocumentProxy | null;
|
||||
container: HTMLElement;
|
||||
onPageNavigate: (pageNumber: number) => void;
|
||||
}
|
||||
|
||||
async function initializePDFLinkHandler(
|
||||
container: HTMLElement,
|
||||
onPageNavigate: (pageNumber: number) => void,
|
||||
doc: PDFDocumentProxy,
|
||||
): Promise<PDFLinkHandlerState> {
|
||||
const state: PDFLinkHandlerState = {
|
||||
doc,
|
||||
container,
|
||||
onPageNavigate,
|
||||
};
|
||||
|
||||
await setupPDFLinks(state);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async function setupPDFLinks(state: PDFLinkHandlerState): Promise<void> {
|
||||
if (!state.doc) return;
|
||||
|
||||
for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) {
|
||||
const page = await state.doc.getPage(pageNum);
|
||||
const annotations = await page.getAnnotations();
|
||||
|
||||
for (const annotation of annotations) {
|
||||
if (annotation.subtype === "Link") {
|
||||
createPDFLinkElement(state, annotation, pageNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPDFLinkElement(
|
||||
state: PDFLinkHandlerState,
|
||||
annotation: any,
|
||||
pageNumber: number,
|
||||
): void {
|
||||
const pageElement = state.container.querySelector(
|
||||
`[data-page-number="${pageNumber}"]`,
|
||||
);
|
||||
if (!pageElement) return;
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.className = "pdf-internal-link";
|
||||
link.href = "javascript:void(0)";
|
||||
|
||||
if (annotation.rect) {
|
||||
const rect = annotation.rect;
|
||||
link.style.position = "absolute";
|
||||
link.style.left = `${rect[0]}px`;
|
||||
link.style.top = `${rect[1]}px`;
|
||||
link.style.width = `${rect[2] - rect[0]}px`;
|
||||
link.style.height = `${rect[3] - rect[1]}px`;
|
||||
link.style.cursor = "pointer";
|
||||
}
|
||||
|
||||
link.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
handlePDFLinkClick(state, annotation);
|
||||
});
|
||||
|
||||
pageElement.appendChild(link);
|
||||
}
|
||||
|
||||
async function handlePDFLinkClick(
|
||||
state: PDFLinkHandlerState,
|
||||
annotation: any,
|
||||
): Promise<void> {
|
||||
if (!state.doc) return;
|
||||
|
||||
if (annotation.url) {
|
||||
if (
|
||||
annotation.url.startsWith("http://") ||
|
||||
annotation.url.startsWith("https://")
|
||||
) {
|
||||
window.open(annotation.url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
console.warn("Unhandled URL:", annotation.url);
|
||||
}
|
||||
} else if (annotation.dest) {
|
||||
const pageNumber = await resolvePDFLinkDestination(state, annotation.dest);
|
||||
state.onPageNavigate(pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePDFLinkDestination(
|
||||
state: PDFLinkHandlerState,
|
||||
dest: string | any[],
|
||||
): Promise<number> {
|
||||
if (!state.doc) return 1;
|
||||
|
||||
try {
|
||||
let explicitDest: any[];
|
||||
|
||||
if (typeof dest === "string") {
|
||||
const destObj = await state.doc.getDestination(dest);
|
||||
if (!destObj) return 1;
|
||||
explicitDest = destObj;
|
||||
} else {
|
||||
explicitDest = dest;
|
||||
}
|
||||
|
||||
const ref = explicitDest[0];
|
||||
|
||||
if (typeof ref === "object" && ref !== null) {
|
||||
const pageIndex = await state.doc.getPageIndex(ref);
|
||||
return pageIndex + 1;
|
||||
} else if (typeof ref === "number") {
|
||||
return ref + 1;
|
||||
}
|
||||
|
||||
return 1;
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve link destination:", error);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
// Mini-map navigation for PDF pages
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface PDFMiniMapState {
|
||||
miniMap: HTMLElement;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
thumbnails: Map<number, HTMLCanvasElement>;
|
||||
onPageNavigate: (pageNumber: number) => void;
|
||||
}
|
||||
|
||||
function createPDFMiniMap(
|
||||
container: HTMLElement,
|
||||
onPageNavigate: (pageNumber: number) => void,
|
||||
): PDFMiniMapState {
|
||||
const miniMap = createMiniMapElement();
|
||||
container.appendChild(miniMap);
|
||||
|
||||
return {
|
||||
miniMap,
|
||||
currentPage: 1,
|
||||
totalPages: 0,
|
||||
thumbnails: new Map(),
|
||||
onPageNavigate,
|
||||
};
|
||||
}
|
||||
|
||||
function createMiniMapElement(): HTMLElement {
|
||||
const miniMap = document.createElement("div");
|
||||
miniMap.className = "pdf-minimap";
|
||||
miniMap.innerHTML = `
|
||||
<div class="pdf-minimap-header">Pages</div>
|
||||
<div class="pdf-minimap-thumbnails"></div>
|
||||
<div class="pdf-minimap-indicator"></div>
|
||||
`;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = getMiniMapStyles();
|
||||
miniMap.appendChild(style);
|
||||
|
||||
return miniMap;
|
||||
}
|
||||
|
||||
async function initializePDFMiniMap(
|
||||
state: PDFMiniMapState,
|
||||
totalPages: number,
|
||||
renderThumbnail: (page: number) => Promise<HTMLCanvasElement>,
|
||||
): Promise<PDFMiniMapState> {
|
||||
const newState = { ...state, totalPages };
|
||||
|
||||
await generateMiniMapThumbnails(newState, renderThumbnail);
|
||||
setupMiniMapEventListeners(newState);
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
async function generateMiniMapThumbnails(
|
||||
state: PDFMiniMapState,
|
||||
renderThumbnail: (page: number) => Promise<HTMLCanvasElement>,
|
||||
): Promise<void> {
|
||||
const container = state.miniMap.querySelector(
|
||||
".pdf-minimap-thumbnails",
|
||||
) as HTMLElement;
|
||||
container.innerHTML = "";
|
||||
|
||||
for (let page = 1; page <= state.totalPages; page++) {
|
||||
try {
|
||||
const thumbnail = await renderThumbnail(page);
|
||||
thumbnail.className = "pdf-minimap-thumbnail";
|
||||
thumbnail.dataset.pageNumber = page.toString();
|
||||
thumbnail.style.width = "80px";
|
||||
thumbnail.style.height = "auto";
|
||||
thumbnail.style.cursor = "pointer";
|
||||
thumbnail.style.marginBottom = "4px";
|
||||
|
||||
container.appendChild(thumbnail);
|
||||
state.thumbnails.set(page, thumbnail);
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate thumbnail for page ${page}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupMiniMapEventListeners(state: PDFMiniMapState): void {
|
||||
const container = state.miniMap.querySelector(".pdf-minimap-thumbnails");
|
||||
|
||||
container?.addEventListener("click", (e) => {
|
||||
const thumbnail = (e.target as HTMLElement).closest(
|
||||
".pdf-minimap-thumbnail",
|
||||
) as HTMLElement;
|
||||
if (thumbnail) {
|
||||
const pageNumber = parseInt(thumbnail.dataset.pageNumber || "1");
|
||||
state.onPageNavigate(pageNumber);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateMiniMapCurrentPage(
|
||||
state: PDFMiniMapState,
|
||||
pageNumber: number,
|
||||
): PDFMiniMapState {
|
||||
const indicator = state.miniMap.querySelector(
|
||||
".pdf-minimap-indicator",
|
||||
) as HTMLElement;
|
||||
const thumbnail = state.thumbnails.get(pageNumber);
|
||||
|
||||
if (thumbnail && indicator) {
|
||||
const rect = thumbnail.getBoundingClientRect();
|
||||
indicator.style.top = `${thumbnail.offsetTop}px`;
|
||||
indicator.style.height = `${rect.height}px`;
|
||||
}
|
||||
|
||||
state.thumbnails.forEach((thumb, page) => {
|
||||
if (page === pageNumber) {
|
||||
thumb.style.outline = "2px solid var(--accent)";
|
||||
thumb.style.opacity = "1";
|
||||
} else {
|
||||
thumb.style.outline = "none";
|
||||
thumb.style.opacity = "0.7";
|
||||
}
|
||||
});
|
||||
|
||||
return { ...state, currentPage: pageNumber };
|
||||
}
|
||||
|
||||
function showMiniMap(state: PDFMiniMapState): void {
|
||||
state.miniMap.style.display = "block";
|
||||
}
|
||||
|
||||
function hideMiniMap(state: PDFMiniMapState): void {
|
||||
state.miniMap.style.display = "none";
|
||||
}
|
||||
|
||||
function toggleMiniMap(state: PDFMiniMapState): void {
|
||||
const isVisible = state.miniMap.style.display !== "none";
|
||||
state.miniMap.style.display = isVisible ? "none" : "block";
|
||||
}
|
||||
|
||||
function getMiniMapStyles(): string {
|
||||
return `
|
||||
.pdf-minimap {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 100px;
|
||||
max-height: 80vh;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--text-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.pdf-minimap-header {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pdf-minimap-thumbnails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pdf-minimap-thumbnail {
|
||||
transition: outline 0.2s, opacity 0.2s;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.pdf-minimap-thumbnail:hover {
|
||||
opacity: 1 !important;
|
||||
outline: 1px solid var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.pdf-minimap-indicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-left: 3px solid var(--accent);
|
||||
pointer-events: none;
|
||||
transition: top 0.3s ease-out;
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
// PDF navigation: page turning, zoom, fit modes
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import { 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";
|
||||
|
||||
interface PDFNavigationState {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
currentScale: number;
|
||||
fitMode: PageFitMode;
|
||||
scrollContainer: HTMLElement | null;
|
||||
}
|
||||
|
||||
function goToPDFPage(
|
||||
state: PDFNavigationState,
|
||||
pageNumber: number,
|
||||
context: ReaderContext,
|
||||
): void {
|
||||
if (pageNumber < 1 || pageNumber > state.totalPages) return;
|
||||
|
||||
state.currentPage = pageNumber;
|
||||
scrollToPDFPage(state, pageNumber);
|
||||
context.events.emit("pdf:page-changed", { page: pageNumber });
|
||||
}
|
||||
|
||||
function nextPDFPage(state: PDFNavigationState, context: ReaderContext): void {
|
||||
if (state.currentPage < state.totalPages) {
|
||||
goToPDFPage(state, state.currentPage + 1, context);
|
||||
}
|
||||
}
|
||||
|
||||
function previousPDFPage(
|
||||
state: PDFNavigationState,
|
||||
context: ReaderContext,
|
||||
): void {
|
||||
if (state.currentPage > 1) {
|
||||
goToPDFPage(state, state.currentPage - 1, context);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToPDFPage(state: PDFNavigationState, pageNumber: number): void {
|
||||
if (!state.scrollContainer) return;
|
||||
|
||||
const pageElement = state.scrollContainer.querySelector(
|
||||
`[data-page-number="${pageNumber}"]`,
|
||||
);
|
||||
if (pageElement) {
|
||||
pageElement.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
|
||||
function setPDFZoom(
|
||||
state: PDFNavigationState,
|
||||
scale: number,
|
||||
context: ReaderContext,
|
||||
): void {
|
||||
state.currentScale = scale;
|
||||
state.fitMode = "none";
|
||||
updatePDFZoom(state);
|
||||
context.events.emit("pdf:zoom-changed", { scale });
|
||||
}
|
||||
|
||||
function setPDFFitMode(
|
||||
state: PDFNavigationState,
|
||||
mode: PageFitMode,
|
||||
context: ReaderContext,
|
||||
): void {
|
||||
state.fitMode = mode;
|
||||
updatePDFZoom(state);
|
||||
context.events.emit("pdf:fit-changed", { mode });
|
||||
}
|
||||
|
||||
function zoomPDFIn(state: PDFNavigationState, context: ReaderContext): void {
|
||||
setPDFZoom(state, state.currentScale * 1.2, context);
|
||||
}
|
||||
|
||||
function zoomPDFOut(state: PDFNavigationState, context: ReaderContext): void {
|
||||
setPDFZoom(state, state.currentScale / 1.2, context);
|
||||
}
|
||||
|
||||
function updatePDFZoom(state: PDFNavigationState): void {
|
||||
const event = new CustomEvent("pdf-update-zoom", {
|
||||
detail: {
|
||||
scale: state.currentScale,
|
||||
fitMode: state.fitMode,
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return state.currentPage;
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
// PDF outline/TOC navigation
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
import { PDFDocumentProxy } from "pdfjs-dist";
|
||||
|
||||
interface PDFOutlineNode {
|
||||
id: string;
|
||||
title: string;
|
||||
destination: number | null;
|
||||
pageNumber?: number;
|
||||
children: PDFOutlineNode[];
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
interface PDFOutlineState {
|
||||
doc: PDFDocumentProxy | null;
|
||||
outline: PDFOutlineNode[];
|
||||
flatMap: Map<string, number>;
|
||||
}
|
||||
|
||||
async function initializePDFOutline(
|
||||
doc: PDFDocumentProxy,
|
||||
): Promise<PDFOutlineState> {
|
||||
const state: PDFOutlineState = {
|
||||
doc,
|
||||
outline: [],
|
||||
flatMap: new Map(),
|
||||
};
|
||||
|
||||
return await loadPDFOutline(state);
|
||||
}
|
||||
|
||||
async function loadPDFOutline(
|
||||
state: PDFOutlineState,
|
||||
): Promise<PDFOutlineState> {
|
||||
if (!state.doc) return state;
|
||||
|
||||
const pdfOutline = await state.doc.getOutline();
|
||||
|
||||
if (!pdfOutline || pdfOutline.length === 0) {
|
||||
return { ...state, outline: [] };
|
||||
}
|
||||
|
||||
const outline = await parseOutlineNodes(state, pdfOutline);
|
||||
|
||||
return { ...state, outline };
|
||||
}
|
||||
|
||||
async function parseOutlineNodes(
|
||||
state: PDFOutlineState,
|
||||
nodes: OutlineTreeNode[],
|
||||
): Promise<PDFOutlineNode[]> {
|
||||
const result: PDFOutlineNode[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
const outlineNode: PDFOutlineNode = {
|
||||
id: generateOutlineId(),
|
||||
title: node.title,
|
||||
destination: null,
|
||||
children: [],
|
||||
expanded: false,
|
||||
};
|
||||
|
||||
if (node.dest) {
|
||||
const pageNumber = await resolvePDFDestination(state, node.dest);
|
||||
outlineNode.destination = pageNumber;
|
||||
outlineNode.pageNumber = pageNumber;
|
||||
state.flatMap.set(node.title, pageNumber);
|
||||
}
|
||||
|
||||
if (node.items && node.items.length > 0) {
|
||||
outlineNode.children = await parseOutlineNodes(state, node.items);
|
||||
}
|
||||
|
||||
result.push(outlineNode);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function resolvePDFDestination(
|
||||
state: PDFOutlineState,
|
||||
dest: string | any[],
|
||||
): Promise<number> {
|
||||
if (!state.doc) return 1;
|
||||
|
||||
try {
|
||||
let explicitDest: any[];
|
||||
|
||||
if (typeof dest === "string") {
|
||||
const destObj = await state.doc.getDestination(dest);
|
||||
if (!destObj) return 1;
|
||||
explicitDest = destObj;
|
||||
} else {
|
||||
explicitDest = dest;
|
||||
}
|
||||
|
||||
const ref = explicitDest[0];
|
||||
|
||||
if (typeof ref === "object" && ref !== null) {
|
||||
const pageIndex = await state.doc.getPageIndex(ref);
|
||||
return pageIndex + 1;
|
||||
} else if (typeof ref === "number") {
|
||||
return ref + 1;
|
||||
}
|
||||
|
||||
return 1;
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve destination:", dest, error);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function generateOutlineId(): string {
|
||||
return `outline-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
function getOutline(state: PDFOutlineState): PDFOutlineNode[] {
|
||||
return state.outline;
|
||||
}
|
||||
|
||||
function getOutlineFlatMap(state: PDFOutlineState): Map<string, number> {
|
||||
return state.flatMap;
|
||||
}
|
||||
|
||||
function getCurrentChapter(
|
||||
state: PDFOutlineState,
|
||||
pageNumber: number,
|
||||
): PDFOutlineNode | null {
|
||||
return findChapterForPage(state.outline, pageNumber);
|
||||
}
|
||||
|
||||
function findChapterForPage(
|
||||
nodes: PDFOutlineNode[],
|
||||
pageNumber: number,
|
||||
): PDFOutlineNode | null {
|
||||
for (const node of nodes) {
|
||||
if (node.pageNumber && node.pageNumber <= pageNumber) {
|
||||
if (node.children.length > 0) {
|
||||
const childMatch = findChapterForPage(node.children, pageNumber);
|
||||
if (childMatch) return childMatch;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node.children.length > 0) {
|
||||
const childMatch = findChapterForPage(node.children, pageNumber);
|
||||
if (childMatch) return childMatch;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toggleOutlineNode(
|
||||
state: PDFOutlineState,
|
||||
nodeId: string,
|
||||
): PDFOutlineState {
|
||||
const updateNode = (nodes: PDFOutlineNode[]): PDFOutlineNode[] => {
|
||||
return nodes.map((node) => {
|
||||
if (node.id === nodeId) {
|
||||
return { ...node, expanded: !node.expanded };
|
||||
}
|
||||
if (node.children.length > 0) {
|
||||
return { ...node, children: updateNode(node.children) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
return { ...state, outline: updateNode(state.outline) };
|
||||
}
|
||||
|
||||
function findOutlineNode(
|
||||
nodes: PDFOutlineNode[],
|
||||
id: string,
|
||||
): PDFOutlineNode | null {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node;
|
||||
if (node.children.length > 0) {
|
||||
const found = findOutlineNode(node.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// Handle PDFs with variable page sizes
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface PageInfo {
|
||||
pageNumber: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
interface PDFPageSizesState {
|
||||
pageSizes: Map<number, PageInfo>;
|
||||
defaultSize: { width: number; height: number };
|
||||
}
|
||||
|
||||
function createPDFPageSizes(): PDFPageSizesState {
|
||||
return {
|
||||
pageSizes: new Map(),
|
||||
defaultSize: { width: 595, height: 842 },
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPDFPageSizes(
|
||||
state: PDFPageSizesState,
|
||||
doc: any,
|
||||
): Promise<PDFPageSizesState> {
|
||||
const pageSizes = new Map<number, PageInfo>();
|
||||
|
||||
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
|
||||
const page = await doc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
|
||||
const pageInfo: PageInfo = {
|
||||
pageNumber: pageNum,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
rotation: viewport.rotation,
|
||||
};
|
||||
|
||||
pageSizes.set(pageNum, pageInfo);
|
||||
}
|
||||
|
||||
return { ...state, pageSizes };
|
||||
}
|
||||
|
||||
function getPDFPageSize(
|
||||
state: PDFPageSizesState,
|
||||
pageNumber: number,
|
||||
): PageInfo | null {
|
||||
return state.pageSizes.get(pageNumber) || null;
|
||||
}
|
||||
|
||||
function isPDFPageLandscape(
|
||||
state: PDFPageSizesState,
|
||||
pageNumber: number,
|
||||
): boolean {
|
||||
const size = getPDFPageSize(state, pageNumber);
|
||||
if (!size) return false;
|
||||
|
||||
const effectiveWidth =
|
||||
size.rotation === 90 || size.rotation === 270 ? size.height : size.width;
|
||||
const effectiveHeight =
|
||||
size.rotation === 90 || size.rotation === 270 ? size.width : size.height;
|
||||
|
||||
return effectiveWidth > effectiveHeight;
|
||||
}
|
||||
|
||||
function getPDFCommonSize(state: PDFPageSizesState): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
if (state.pageSizes.size === 0) {
|
||||
return state.defaultSize;
|
||||
}
|
||||
|
||||
const sizeGroups: Map<
|
||||
string,
|
||||
{ width: number; height: number; count: number }
|
||||
> = new Map();
|
||||
|
||||
state.pageSizes.forEach((size) => {
|
||||
const key = getPageSizeKey(size.width, size.height);
|
||||
const existing = sizeGroups.get(key);
|
||||
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
sizeGroups.set(key, { width: size.width, height: size.height, count: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
let mostCommon = state.defaultSize;
|
||||
let maxCount = 0;
|
||||
|
||||
sizeGroups.forEach((size) => {
|
||||
if (size.count > maxCount) {
|
||||
maxCount = size.count;
|
||||
mostCommon = { width: size.width, height: size.height };
|
||||
}
|
||||
});
|
||||
|
||||
return mostCommon;
|
||||
}
|
||||
|
||||
function getPageSizeKey(width: number, height: number): string {
|
||||
const w = Math.round(width / 10) * 10;
|
||||
const h = Math.round(height / 10) * 10;
|
||||
return `${w}x${h}`;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// Handle rotated/landscape pages in PDFs
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface PDFRotationState {
|
||||
rotations: Map<number, number>;
|
||||
}
|
||||
|
||||
function createPDFRotation(): PDFRotationState {
|
||||
return {
|
||||
rotations: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPDFPageRotations(
|
||||
state: PDFRotationState,
|
||||
doc: any,
|
||||
): Promise<PDFRotationState> {
|
||||
const rotations = new Map<number, number>();
|
||||
|
||||
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
|
||||
const page = await doc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const rotation = viewport.rotation;
|
||||
|
||||
if (rotation !== 0) {
|
||||
rotations.set(pageNum, rotation);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...state, rotations };
|
||||
}
|
||||
|
||||
function getPDFPageRotation(
|
||||
state: PDFRotationState,
|
||||
pageNumber: number,
|
||||
): number {
|
||||
return state.rotations.get(pageNumber) || 0;
|
||||
}
|
||||
|
||||
function hasPDFPageRotation(
|
||||
state: PDFRotationState,
|
||||
pageNumber: number,
|
||||
): boolean {
|
||||
return state.rotations.has(pageNumber);
|
||||
}
|
||||
|
||||
function applyPDFRotation(
|
||||
state: PDFRotationState,
|
||||
canvas: HTMLCanvasElement,
|
||||
pageNumber: number,
|
||||
): void {
|
||||
const rotation = getPDFPageRotation(state, pageNumber);
|
||||
|
||||
if (rotation === 0) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||
ctx.rotate((rotation * Math.PI) / 180);
|
||||
ctx.translate(-canvas.width / 2, -canvas.height / 2);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function getPDFAdjustedViewport(
|
||||
state: PDFRotationState,
|
||||
pageNumber: number,
|
||||
viewport: any,
|
||||
): any {
|
||||
const rotation = getPDFPageRotation(state, pageNumber);
|
||||
|
||||
if (rotation === 0 || rotation === 180) {
|
||||
return viewport;
|
||||
}
|
||||
|
||||
return {
|
||||
...viewport,
|
||||
width: viewport.height,
|
||||
height: viewport.width,
|
||||
};
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
// Full-text search within PDF documents
|
||||
|
||||
import { PDFDocumentProxy } from "pdfjs-dist";
|
||||
|
||||
interface SearchResult {
|
||||
pageNumber: number;
|
||||
text: string;
|
||||
index: number;
|
||||
context: string;
|
||||
}
|
||||
|
||||
// Full-text search within PDF documents
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface SearchResult {
|
||||
pageNumber: number;
|
||||
text: string;
|
||||
index: number;
|
||||
context: string;
|
||||
}
|
||||
|
||||
interface PDFSearchState {
|
||||
doc: PDFDocumentProxy | null;
|
||||
searchResults: SearchResult[];
|
||||
currentResultIndex: number;
|
||||
}
|
||||
|
||||
async function initializePDFSearch(
|
||||
doc: PDFDocumentProxy,
|
||||
): Promise<PDFSearchState> {
|
||||
return {
|
||||
doc,
|
||||
searchResults: [],
|
||||
currentResultIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function searchPDF(
|
||||
state: PDFSearchState,
|
||||
query: string,
|
||||
): Promise<PDFSearchState> {
|
||||
if (!state.doc) return state;
|
||||
|
||||
const searchResults: SearchResult[] = [];
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) {
|
||||
const page = await state.doc.getPage(pageNum);
|
||||
const textContent = await page.getTextContent();
|
||||
|
||||
let fullText = "";
|
||||
const textItems = textContent.items.map((item) => {
|
||||
if (typeof item === "string") return "";
|
||||
fullText += item.str;
|
||||
return item.str;
|
||||
});
|
||||
|
||||
const pageText = textItems.join(" ");
|
||||
const matches = findSearchMatches(pageText, lowerQuery, pageNum);
|
||||
|
||||
searchResults.push(...matches);
|
||||
}
|
||||
|
||||
return { ...state, searchResults };
|
||||
}
|
||||
|
||||
function findSearchMatches(
|
||||
text: string,
|
||||
query: string,
|
||||
pageNumber: number,
|
||||
): SearchResult[] {
|
||||
const matches: SearchResult[] = [];
|
||||
const lowerText = text.toLowerCase();
|
||||
let index = 0;
|
||||
|
||||
while ((index = lowerText.indexOf(query, index)) !== -1) {
|
||||
const start = Math.max(0, index - 50);
|
||||
const end = Math.min(text.length, index + query.length + 50);
|
||||
const context = text.slice(start, end);
|
||||
|
||||
matches.push({
|
||||
pageNumber,
|
||||
text: text.slice(index, index + query.length),
|
||||
index,
|
||||
context,
|
||||
});
|
||||
|
||||
index += query.length;
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function goToNextSearchResult(
|
||||
state: PDFSearchState,
|
||||
): PDFSearchState & { result: SearchResult | null } {
|
||||
if (state.searchResults.length === 0) {
|
||||
return { ...state, result: null };
|
||||
}
|
||||
|
||||
const newIndex = (state.currentResultIndex + 1) % state.searchResults.length;
|
||||
return {
|
||||
...state,
|
||||
currentResultIndex: newIndex,
|
||||
result: state.searchResults[newIndex],
|
||||
};
|
||||
}
|
||||
|
||||
function goToPreviousSearchResult(
|
||||
state: PDFSearchState,
|
||||
): PDFSearchState & { result: SearchResult | null } {
|
||||
if (state.searchResults.length === 0) {
|
||||
return { ...state, result: null };
|
||||
}
|
||||
|
||||
const newIndex =
|
||||
(state.currentResultIndex - 1 + state.searchResults.length) %
|
||||
state.searchResults.length;
|
||||
return {
|
||||
...state,
|
||||
currentResultIndex: newIndex,
|
||||
result: state.searchResults[newIndex],
|
||||
};
|
||||
}
|
||||
|
||||
function getSearchResultCount(state: PDFSearchState): number {
|
||||
return state.searchResults.length;
|
||||
}
|
||||
|
||||
function clearSearchResults(state: PDFSearchState): PDFSearchState {
|
||||
return {
|
||||
...state,
|
||||
searchResults: [],
|
||||
currentResultIndex: 0,
|
||||
};
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
// PDF text selection - Uses backend API for highlight creation
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import { 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;
|
||||
text: string;
|
||||
rects: DOMRect[];
|
||||
}
|
||||
|
||||
export function getPDFTextSelection(): PDFTextSelection | null {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return null;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const text = range.toString();
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
const pageElement = range.commonAncestorContainer.parentElement?.closest?.(
|
||||
"[data-page-number]",
|
||||
) as HTMLElement;
|
||||
const pageNumber = pageElement?.dataset.pageNumber
|
||||
? parseInt(pageElement.dataset.pageNumber)
|
||||
: getCurrentPDFPage();
|
||||
|
||||
const rects: DOMRect[] = [];
|
||||
for (const rect of Array.from(range.getClientRects())) {
|
||||
rects.push(rect);
|
||||
}
|
||||
|
||||
return {
|
||||
pageNumber,
|
||||
text,
|
||||
rects,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPDFHighlight(
|
||||
mediaItemId: string,
|
||||
selection: PDFTextSelection,
|
||||
color: string,
|
||||
): Promise<any> {
|
||||
const selectionData = {
|
||||
selection_text: selection.text,
|
||||
page_number: selection.pageNumber,
|
||||
rects: selection.rects.map((rect) => ({
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
})),
|
||||
color,
|
||||
};
|
||||
|
||||
const response = await fetch(`/api/media-items/${mediaItemId}/highlights`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(selectionData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create highlight: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function loadAndRenderPDFHighlights(
|
||||
mediaItemId: string,
|
||||
container: HTMLElement,
|
||||
): Promise<void> {
|
||||
const response = await fetch(`/api/media-items/${mediaItemId}/highlights`);
|
||||
if (!response.ok) return;
|
||||
|
||||
const highlights: any[] = await response.json();
|
||||
|
||||
for (const highlight of highlights) {
|
||||
renderPDFHighlight(container, highlight);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPDFHighlight(container: HTMLElement, highlight: any): void {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "pdf-highlight-annotation";
|
||||
overlay.dataset.highlightId = highlight.id;
|
||||
overlay.style.backgroundColor = parseColor(highlight.color || "#ffff00");
|
||||
|
||||
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 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 getCurrentPDFPage(): number {
|
||||
const pageElement = document.querySelector("[data-page-number]");
|
||||
return pageElement
|
||||
? parseInt(pageElement.getAttribute("data-page-number") || "1")
|
||||
: 1;
|
||||
}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
// Mozilla pdf.js integration for PDF rendering
|
||||
// Procedural style: Functions, not classes
|
||||
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
|
||||
// ============================================================
|
||||
// PDF.js Configuration
|
||||
// ============================================================
|
||||
|
||||
export function configurePDFJS(): void {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "/static/js/pdf.worker.min.mjs";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PDF Document State
|
||||
// ============================================================
|
||||
|
||||
interface PDFDocumentState {
|
||||
doc: pdfjsLib.PDFDocumentProxy | null;
|
||||
pages: Map<number, pdfjsLib.PDFPageProxy>;
|
||||
metadata: PDFMetadata | null;
|
||||
}
|
||||
|
||||
interface PDFMetadata {
|
||||
title: string;
|
||||
author: string;
|
||||
subject?: string;
|
||||
keywords?: string;
|
||||
creator?: string;
|
||||
producer?: string;
|
||||
creationDate?: Date;
|
||||
modificationDate?: Date;
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
let pdfState: PDFDocumentState = {
|
||||
doc: null,
|
||||
pages: new Map(),
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Document Loading
|
||||
// ============================================================
|
||||
|
||||
export async function loadPDFDocument(pdfBlob: Blob): Promise<PDFMetadata> {
|
||||
// Cleanup previous document
|
||||
unloadPDFDocument();
|
||||
|
||||
const arrayBuffer = await pdfBlob.arrayBuffer();
|
||||
const loadingTask = pdfjsLib.getDocument({
|
||||
data: arrayBuffer,
|
||||
});
|
||||
|
||||
pdfState.doc = await loadingTask.promise;
|
||||
|
||||
// Extract metadata
|
||||
const metadata = await pdfState.doc.getMetadata().catch(() => null);
|
||||
const info = metadata?.info || {};
|
||||
|
||||
pdfState.metadata = {
|
||||
title: info.Title || "Untitled",
|
||||
author: info.Author || "Unknown",
|
||||
subject: info.Subject,
|
||||
keywords: info.Keywords,
|
||||
creator: info.Creator,
|
||||
producer: info.Producer,
|
||||
creationDate: info.CreationDate ? new Date(info.CreationDate) : undefined,
|
||||
modificationDate: info.ModDate ? new Date(info.ModDate) : undefined,
|
||||
pageCount: pdfState.doc.numPages,
|
||||
};
|
||||
|
||||
return pdfState.metadata;
|
||||
}
|
||||
|
||||
export async function getPDFPage(
|
||||
pageNumber: number,
|
||||
): Promise<pdfjsLib.PDFPageProxy> {
|
||||
if (!pdfState.doc) {
|
||||
throw new Error("PDF document not loaded");
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if (pdfState.pages.has(pageNumber)) {
|
||||
return pdfState.pages.get(pageNumber)!;
|
||||
}
|
||||
|
||||
// Load page
|
||||
const page = await pdfState.doc.getPage(pageNumber);
|
||||
pdfState.pages.set(pageNumber, page);
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Reader Initialization
|
||||
// ============================================================
|
||||
interface PDFReader {
|
||||
type: "pdf";
|
||||
doc: any;
|
||||
currentPage: number;
|
||||
}
|
||||
export async function initializePDFReader(
|
||||
metadata: ReaderMetadata,
|
||||
): Promise<PDFReader> {
|
||||
configurePDFJS();
|
||||
const response = await fetch(metadata.file_path);
|
||||
const pdfBlob = await response.blob();
|
||||
await loadPDFDocument(pdfBlob);
|
||||
return {
|
||||
type: "pdf",
|
||||
doc: pdfState.doc,
|
||||
currentPage: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPDFPageText(pageNumber: number): Promise<any> {
|
||||
const page = await getPDFPage(pageNumber);
|
||||
return await page.getTextContent();
|
||||
}
|
||||
|
||||
export function getPDFMetadata(): PDFMetadata | null {
|
||||
return pdfState.metadata;
|
||||
}
|
||||
|
||||
export function getPDFPageCount(): number {
|
||||
return pdfState.doc?.numPages || 0;
|
||||
}
|
||||
|
||||
export function unloadPDFDocument(): void {
|
||||
pdfState.pages.clear();
|
||||
pdfState.doc = null;
|
||||
pdfState.metadata = null;
|
||||
}
|
||||
|
||||
export function unloadPDFPage(pageNumber: number): void {
|
||||
pdfState.pages.delete(pageNumber);
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
// Text layer rendering for PDF text selection and highlighting
|
||||
// Procedural style: Functions, not classes
|
||||
|
||||
// ============================================================
|
||||
// Render Functions
|
||||
// ============================================================
|
||||
|
||||
export function renderTextLayer(
|
||||
container: HTMLElement,
|
||||
viewport: any,
|
||||
textContent: any,
|
||||
config: TextLayerConfig,
|
||||
): void {
|
||||
// Clear container
|
||||
container.innerHTML = "";
|
||||
|
||||
// Apply styles
|
||||
applyTextLayerStyles(container, config);
|
||||
|
||||
// Render text items
|
||||
const { items } = textContent;
|
||||
|
||||
items.forEach((item: any, index: number) => {
|
||||
if (typeof item === "string") return;
|
||||
|
||||
const textDiv = createTextDiv(item, viewport, index);
|
||||
container.appendChild(textDiv);
|
||||
});
|
||||
}
|
||||
|
||||
function createTextDiv(item: any, viewport: any, index: number): HTMLElement {
|
||||
const div = document.createElement("div");
|
||||
div.className = "pdf-text-layer-text";
|
||||
div.textContent = item.str;
|
||||
div.dataset.index = index.toString();
|
||||
|
||||
// Position the text div
|
||||
const tx = pdfjsLib.Util.transform(viewport.transform, item.transform);
|
||||
|
||||
const fontSize = Math.sqrt(tx[0] * tx[0] + tx[1] * tx[1]);
|
||||
|
||||
div.style.left = `${tx[4]}px`;
|
||||
div.style.top = `${tx[5] - fontSize}px`;
|
||||
div.style.fontSize = `${fontSize}px`;
|
||||
div.style.fontFamily = item.fontName || "sans-serif";
|
||||
|
||||
// Handle text direction
|
||||
if (item.dir === "ttb") {
|
||||
div.style.writingMode = "vertical-rl";
|
||||
}
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
interface TextLayerConfig {
|
||||
theme: "light" | "sepia" | "dark" | "night" | "high-contrast";
|
||||
}
|
||||
|
||||
function applyTextLayerStyles(
|
||||
container: HTMLElement,
|
||||
config: TextLayerConfig,
|
||||
): void {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = getTextLayerCSS(config.theme);
|
||||
container.appendChild(style);
|
||||
}
|
||||
|
||||
function getTextLayerCSS(theme: string): string {
|
||||
const colors = getThemeColors(theme);
|
||||
|
||||
return `
|
||||
.pdf-text-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
line-height: 1;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pdf-text-layer-text {
|
||||
position: absolute;
|
||||
white-space: pre;
|
||||
cursor: text;
|
||||
transform-origin: 0% 0%;
|
||||
color: transparent;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.pdf-text-layer-text::selection {
|
||||
background: ${colors.highlight};
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.pdf-text-layer-text::-moz-selection {
|
||||
background: ${colors.highlight};
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.pdf-highlight-overlay {
|
||||
position: absolute;
|
||||
background-color: ${colors.highlight};
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function getThemeColors(theme: string): { highlight: string } {
|
||||
const themes: Record<string, { highlight: string }> = {
|
||||
light: { highlight: "rgba(255, 255, 0, 0.3)" },
|
||||
sepia: { highlight: "rgba(255, 200, 0, 0.4)" },
|
||||
dark: { highlight: "rgba(255, 255, 0, 0.3)" },
|
||||
night: { highlight: "rgba(100, 150, 255, 0.3)" },
|
||||
"high-contrast": { highlight: "rgba(255, 255, 0, 0.5)" },
|
||||
};
|
||||
|
||||
return themes[theme] || themes["dark"];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Selection Functions
|
||||
// ============================================================
|
||||
|
||||
export function getPDFTextSelection(): { text: string; range: Range } | null {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return null;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const text = range.toString();
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
return { text, range };
|
||||
}
|
||||
|
||||
export function getPDFSelectionRects(): DOMRect[] {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return [];
|
||||
|
||||
const rects: DOMRect[] = [];
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
for (const rect of range.getClientRects()) {
|
||||
rects.push(rect);
|
||||
}
|
||||
|
||||
return rects;
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// Render a page's content to the DOM
|
||||
export function renderPage(container: HTMLElement, content: string): void {
|
||||
container.innerHTML = "";
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "reflowable-page";
|
||||
wrapper.style.height = "calc(100vh - 120px)";
|
||||
wrapper.style.overflow = "hidden";
|
||||
wrapper.style.position = "relative";
|
||||
wrapper.style.display = "flex";
|
||||
wrapper.style.flexDirection = "column";
|
||||
|
||||
// Parse the HTML content (which is already sliced by getPageContent)
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = content;
|
||||
const pageContent = tempDiv.querySelector(".page-content-wrapper");
|
||||
|
||||
if (!pageContent) {
|
||||
// Fallback if wrapper not found
|
||||
const contentDiv = document.createElement("div");
|
||||
contentDiv.className = "page-content";
|
||||
contentDiv.innerHTML = content;
|
||||
contentDiv.style.height = "100%";
|
||||
contentDiv.style.overflow = "hidden";
|
||||
contentDiv.style.flex = "1";
|
||||
contentDiv.style.overflowY = "auto";
|
||||
wrapper.appendChild(contentDiv);
|
||||
} else {
|
||||
// Transfer the sliced content to our wrapper
|
||||
const contentDiv = document.createElement("div");
|
||||
contentDiv.className = "page-content";
|
||||
contentDiv.style.height = "100%";
|
||||
contentDiv.style.overflow = "hidden";
|
||||
contentDiv.style.flex = "1";
|
||||
contentDiv.style.padding = "20px";
|
||||
|
||||
while (pageContent.firstChild) {
|
||||
contentDiv.appendChild(pageContent.firstChild);
|
||||
}
|
||||
|
||||
wrapper.appendChild(contentDiv);
|
||||
}
|
||||
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
// Update container styles for paginated mode
|
||||
export function applyPaginatedStyles(): void {
|
||||
const existing = document.getElementById("reflowable-styles");
|
||||
existing?.remove();
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = "reflowable-styles";
|
||||
style.textContent = `
|
||||
.reflowable-page {
|
||||
height: calc(100vh - 120px) !important;
|
||||
overflow: hidden !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
height: 100% !important;
|
||||
overflow: hidden !important;
|
||||
-webkit-column-width: auto !important;
|
||||
column-width: auto !important;
|
||||
-webkit-column-count: 1 !important;
|
||||
column-count: 1 !important;
|
||||
-webkit-column-fill: auto !important;
|
||||
column-fill: auto !important;
|
||||
}
|
||||
|
||||
.page-content img {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.page-content p {
|
||||
margin: 0.5em 0 !important;
|
||||
text-align: justify !important;
|
||||
}
|
||||
|
||||
.page-content h1,
|
||||
.page-content h2,
|
||||
.page-content h3,
|
||||
.page-content h4,
|
||||
.page-content h5,
|
||||
.page-content h6 {
|
||||
margin: 1em 0 0.5em 0 !important;
|
||||
page-break-after: avoid !important;
|
||||
break-after: avoid !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Clear all styles
|
||||
export function clearPaginatedStyles(): void {
|
||||
const existing = document.getElementById("reflowable-styles");
|
||||
existing?.remove();
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Handle text copying with citation
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
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;
|
||||
|
||||
const selectedText = selection.toString();
|
||||
if (!selectedText.trim()) return false;
|
||||
|
||||
const citation = createCitation(selectedText, mediaItem);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(citation);
|
||||
showToast("Copied to clipboard", "success");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to copy:", error);
|
||||
showToast("Failed to copy to clipboard", "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createCitation(text: string, mediaItem: MediaItemSummary): string {
|
||||
let citation = `"${text}"\n`;
|
||||
citation += `— ${mediaItem.title}`;
|
||||
if (mediaItem.author) {
|
||||
citation += ` by ${mediaItem.author}`;
|
||||
}
|
||||
citation += `\n(Source: Bookhoard)`;
|
||||
|
||||
return citation;
|
||||
}
|
||||
|
||||
function enableContextMenuCopy(mediaItem: MediaItemSummary): void {
|
||||
document.addEventListener("contextmenu", async (e) => {
|
||||
const selection = window.getSelection();
|
||||
const selectedText = selection?.toString().trim();
|
||||
|
||||
if (selectedText) {
|
||||
e.preventDefault();
|
||||
await copySelection(mediaItem);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// Dictionary lookup popup for ebooks
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import { 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 {
|
||||
const existing = document.getElementById("dictionary-popup");
|
||||
existing?.remove();
|
||||
|
||||
const popup = document.createElement("div");
|
||||
popup.id = "dictionary-popup";
|
||||
popup.className =
|
||||
"absolute bg-white text-black p-4 rounded-lg shadow-xl max-w-md z-50";
|
||||
popup.style.left = `${position.x}px`;
|
||||
popup.style.top = `${position.y}px`;
|
||||
|
||||
popup.innerHTML = '<p class="text-sm">Loading...</p>';
|
||||
document.body.appendChild(popup);
|
||||
|
||||
lookupWord(word)
|
||||
.then((entry) => {
|
||||
popup.innerHTML = `
|
||||
<h3 class="font-bold text-lg">${entry.word}</h3>
|
||||
<p class="text-sm italic">${entry.part_of_speech || ""}</p>
|
||||
<p class="mt-2">${entry.definition}</p>
|
||||
${entry.example ? `<p class="mt-2 text-sm italic">"${entry.example}"</p>` : ""}
|
||||
`;
|
||||
})
|
||||
.catch(() => {
|
||||
popup.innerHTML = `<p class="text-red-500">Definition not found for "${word}"</p>`;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener("click", function closePopup(e: MouseEvent) {
|
||||
if (!popup.contains(e.target as Node)) {
|
||||
popup.remove();
|
||||
document.removeEventListener("click", closePopup);
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function handleTextSelection(): void {
|
||||
document.addEventListener("mouseup", () => {
|
||||
const selection = window.getSelection();
|
||||
const selectedText = selection?.toString().trim();
|
||||
|
||||
if (selectedText && selectedText.split(" ").length === 1) {
|
||||
const range = selection?.getRangeAt(0);
|
||||
const rect = range?.getBoundingClientRect();
|
||||
|
||||
if (rect) {
|
||||
showDictionaryPopup(selectedText, { x: rect.left, y: rect.bottom });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
// Font loading with performance optimization
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import { 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 = [
|
||||
{
|
||||
id: "literata",
|
||||
name: "Literata",
|
||||
stack: "Literata, serif",
|
||||
description: "Designed for Google Play Books",
|
||||
},
|
||||
{
|
||||
id: "crimson",
|
||||
name: "Crimson Text",
|
||||
stack: "Crimson Text, serif",
|
||||
description: "Optimized for screen reading",
|
||||
},
|
||||
{
|
||||
id: "source-serif",
|
||||
name: "Source Serif 4",
|
||||
stack: "Source Serif 4, serif",
|
||||
description: "Professional Adobe quality",
|
||||
},
|
||||
{
|
||||
id: "eb-garamond",
|
||||
name: "EB Garamond",
|
||||
stack: "EB Garamond, serif",
|
||||
description: "Classic elegance",
|
||||
},
|
||||
{
|
||||
id: "libertinus",
|
||||
name: "Libertinus Serif",
|
||||
stack: "Libertinus Serif, serif",
|
||||
description: "Excellent for technical content",
|
||||
},
|
||||
{
|
||||
id: "noto-serif",
|
||||
name: "Noto Serif",
|
||||
stack: "Noto Serif, serif",
|
||||
description: "Maximum language support",
|
||||
},
|
||||
{
|
||||
id: "charis-sil",
|
||||
name: "Charis SIL",
|
||||
stack: "Charis SIL, serif",
|
||||
description: "Multilingual specialist",
|
||||
},
|
||||
{
|
||||
id: "ibm-plex",
|
||||
name: "IBM Plex Serif",
|
||||
stack: "IBM Plex Serif, serif",
|
||||
description: "Modern & versatile",
|
||||
},
|
||||
];
|
||||
|
||||
async function preloadFonts(userPreferredFont: string): Promise<void> {
|
||||
const fontsToPreload = new Set(["literata", userPreferredFont]);
|
||||
|
||||
for (const fontId of fontsToPreload) {
|
||||
const font = READING_FONTS.find((f) => f.id === fontId);
|
||||
if (font) {
|
||||
document.fonts.load(`16px "${font.stack}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFontStack(fontId: string): string {
|
||||
const font = READING_FONTS.find((f) => f.id === fontId);
|
||||
return font?.stack || "Literata, serif";
|
||||
}
|
||||
|
||||
function applyFontStack(stack: string): void {
|
||||
document.documentElement.style.setProperty("--reader-font-family", stack);
|
||||
}
|
||||
|
||||
export { READING_FONTS, preloadFonts, getFontStack };
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
// Search within ebook content
|
||||
// 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;
|
||||
snippet: string;
|
||||
chapterTitle: string;
|
||||
}
|
||||
|
||||
export async function searchEbook(
|
||||
ebookData: any,
|
||||
query: string,
|
||||
): Promise<SearchResult[]> {
|
||||
const results: SearchResult[] = [];
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
if (!ebookData.spine) return results;
|
||||
|
||||
for (const spineItem of ebookData.spine) {
|
||||
const doc = await getSpineItemDocument(ebookData, spineItem);
|
||||
|
||||
if (!doc) continue;
|
||||
|
||||
const chapterTitle = getChapterTitle(spineItem);
|
||||
const textNodes = findTextNodes(doc.body);
|
||||
|
||||
for (const node of textNodes) {
|
||||
const text = node.textContent || "";
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
let foundAt = 0;
|
||||
while ((foundAt = lowerText.indexOf(lowerQuery, foundAt)) !== -1) {
|
||||
const cfi = generateCFIForNode(node, foundAt);
|
||||
const snippet = extractSnippet(text, foundAt, query.length);
|
||||
|
||||
results.push({
|
||||
cfi,
|
||||
snippet,
|
||||
chapterTitle,
|
||||
});
|
||||
|
||||
foundAt += lowerQuery.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function getSpineItemDocument(
|
||||
ebookData: any,
|
||||
spineItem: any,
|
||||
): Promise<Document | null> {
|
||||
try {
|
||||
const resources = ebookData.resources;
|
||||
if (!resources) return null;
|
||||
|
||||
const content = await resources.get(spineItem.href)?.text();
|
||||
if (!content) return null;
|
||||
|
||||
const parser = new DOMParser();
|
||||
return parser.parseFromString(content, "text/html");
|
||||
} catch (error) {
|
||||
console.error("Failed to load spine item:", spineItem.href, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getChapterTitle(spineItem: any): string {
|
||||
return spineItem.id || `Section ${spineItem.index || ""}`;
|
||||
}
|
||||
|
||||
function findTextNodes(root: Node): Text[] {
|
||||
const textNodes: Text[] = [];
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
const parent = node.parentElement;
|
||||
if (parent && ["SCRIPT", "STYLE", "NOSCRIPT"].includes(parent.tagName)) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
|
||||
if (!node.textContent?.trim()) {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
});
|
||||
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
textNodes.push(node as Text);
|
||||
}
|
||||
|
||||
return textNodes;
|
||||
}
|
||||
|
||||
function generateCFIForNode(node: Node, offset: number): string {
|
||||
const path: number[] = [];
|
||||
let current: Node | null = node;
|
||||
|
||||
while (current && current.parentNode) {
|
||||
const siblings = Array.from(current.parentNode.childNodes);
|
||||
const index = siblings.indexOf(current as ChildNode);
|
||||
path.unshift(index);
|
||||
current = current.parentNode;
|
||||
}
|
||||
|
||||
return `/6/4${path.map((i) => `/${i + 2}`).join("")}:${offset}`;
|
||||
}
|
||||
|
||||
function extractSnippet(text: string, offset: number, length: number): string {
|
||||
const start = Math.max(0, offset - 40);
|
||||
const end = Math.min(text.length, offset + length + 40);
|
||||
let snippet = text.substring(start, end);
|
||||
|
||||
if (start > 0) snippet = "..." + snippet;
|
||||
if (end < text.length) snippet = snippet + "...";
|
||||
|
||||
return snippet;
|
||||
}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
// Typography engine for ebook rendering
|
||||
// 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:
|
||||
| "literata"
|
||||
| "crimson"
|
||||
| "source-serif"
|
||||
| "eb-garamond"
|
||||
| "libertinus"
|
||||
| "noto-serif"
|
||||
| "charis-sil"
|
||||
| "ibm-plex";
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
marginTop: number;
|
||||
marginBottom: number;
|
||||
marginLeft: number;
|
||||
marginRight: number;
|
||||
textAlign: "left" | "right" | "center" | "justify";
|
||||
textIndent: number;
|
||||
hyphenate: boolean;
|
||||
ligatures: boolean;
|
||||
fontSmoothing: "auto" | "antialiased" | "subpixel-antialiased";
|
||||
}
|
||||
|
||||
function applyTypography(
|
||||
container: HTMLElement,
|
||||
config: TypographyConfig,
|
||||
): void {
|
||||
const content = container.querySelector(".ebook-content");
|
||||
if (!content) return;
|
||||
|
||||
const fontStack = getFontStack(config.readingFont);
|
||||
|
||||
content.setAttribute(
|
||||
"style",
|
||||
`
|
||||
font-family: ${fontStack};
|
||||
font-size: ${config.fontSize}px;
|
||||
line-height: ${config.lineHeight};
|
||||
text-align: ${config.textAlign};
|
||||
margin-top: ${config.marginTop}px;
|
||||
margin-bottom: ${config.marginBottom}px;
|
||||
margin-left: ${config.marginLeft}px;
|
||||
margin-right: ${config.marginRight}px;
|
||||
text-indent: ${config.textIndent}px;
|
||||
-webkit-font-smoothing: ${config.fontSmoothing};
|
||||
-moz-osx-font-smoothing: auto;
|
||||
`,
|
||||
);
|
||||
|
||||
if (config.hyphenate) {
|
||||
enableHyphenation(container, content as HTMLElement);
|
||||
}
|
||||
|
||||
setLigatures(content as HTMLElement, config.ligatures);
|
||||
|
||||
if (config.textAlign === "justify") {
|
||||
enableJustification(content as HTMLElement);
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
const lang =
|
||||
container.closest("[data-language]")?.getAttribute("data-language") || "en";
|
||||
element.setAttribute("lang", lang);
|
||||
}
|
||||
|
||||
function setLigatures(element: HTMLElement, enabled: boolean): void {
|
||||
if (enabled) {
|
||||
element.style.fontVariantLigatures = "common-ligatures";
|
||||
element.style.fontFeatureSettings = '"liga", "dlig"';
|
||||
} else {
|
||||
element.style.fontVariantLigatures = "no-common-ligatures";
|
||||
element.style.fontFeatureSettings = "normal";
|
||||
}
|
||||
}
|
||||
|
||||
function enableJustification(element: HTMLElement): void {
|
||||
element.style.wordBreak = "normal";
|
||||
element.style.overflowWrap = "break-word";
|
||||
element.style.wordWrap = "break-word";
|
||||
element.style.letterSpacing = "0.01em";
|
||||
}
|
||||
|
||||
function updateTypographyConfig(
|
||||
currentConfig: TypographyConfig,
|
||||
newConfig: Partial<TypographyConfig>,
|
||||
): TypographyConfig {
|
||||
return { ...currentConfig, ...newConfig };
|
||||
}
|
||||
|
||||
function measureReadingTime(
|
||||
container: HTMLElement,
|
||||
wordsPerMinute: number = 250,
|
||||
): number {
|
||||
const content = container.querySelector(".ebook-content");
|
||||
if (!content) return 0;
|
||||
|
||||
const text = content.textContent || "";
|
||||
const words = text.split(/\s+/).length;
|
||||
const minutes = words / wordsPerMinute;
|
||||
|
||||
return Math.ceil(minutes);
|
||||
}
|
||||
|
||||
export { applyTypography, getFontStack };
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
// Import types
|
||||
import type { ReadingPosition } from "./types";
|
||||
import {
|
||||
getPageContent,
|
||||
findPageByCFI,
|
||||
createPositionFromPage,
|
||||
} from "./page-calculator";
|
||||
import { UniversalReader } from "../../reader-shell";
|
||||
|
||||
// Navigate to specific page
|
||||
export function goToPage(
|
||||
book: UniversalReader,
|
||||
targetPage: number,
|
||||
): {
|
||||
success: boolean;
|
||||
position: ReadingPosition;
|
||||
content: string;
|
||||
} {
|
||||
if (!book.pagination) {
|
||||
return { success: false, position: createDefaultPosition(), content: "" };
|
||||
}
|
||||
|
||||
const pageIndex = Math.max(
|
||||
0,
|
||||
Math.min(targetPage - 1, book.pagination.totalPages - 1),
|
||||
);
|
||||
const content = getPageContent(book.pagination, pageIndex);
|
||||
const position = createPositionFromPage(book, pageIndex + 1);
|
||||
|
||||
return { success: true, position, content };
|
||||
}
|
||||
|
||||
// Navigate to next page
|
||||
export function nextPage(book: UniversalReader): {
|
||||
success: boolean;
|
||||
position: ReadingPosition;
|
||||
content: string;
|
||||
} {
|
||||
const nextPageNum = book.position.currentPage + 1;
|
||||
return goToPage(book, nextPageNum);
|
||||
}
|
||||
|
||||
// Navigate to previous page
|
||||
export function previousPage(book: UniversalReader): {
|
||||
success: boolean;
|
||||
position: ReadingPosition;
|
||||
content: string;
|
||||
} {
|
||||
const prevPageNum = book.position.currentPage - 1;
|
||||
return goToPage(book, prevPageNum);
|
||||
}
|
||||
|
||||
// Jump to specific CFI
|
||||
export function goToCFI(
|
||||
book: UniversalReader,
|
||||
cfi: string,
|
||||
): {
|
||||
success: boolean;
|
||||
position: ReadingPosition;
|
||||
content: string;
|
||||
} {
|
||||
if (!book.pagination) {
|
||||
return { success: false, position: createDefaultPosition(), content: "" };
|
||||
}
|
||||
|
||||
const pageNum = findPageByCFI(book.pagination, cfi);
|
||||
return goToPage(book, pageNum);
|
||||
}
|
||||
|
||||
// Create default position
|
||||
function createDefaultPosition(): ReadingPosition {
|
||||
return {
|
||||
currentPage: 1,
|
||||
spineIndex: 0,
|
||||
localPageIndex: 0,
|
||||
cfi: "",
|
||||
progress: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if navigation is possible
|
||||
export function canGoNext(book: UniversalReader): boolean {
|
||||
return book.position.currentPage < (book.pagination?.totalPages || 1);
|
||||
}
|
||||
|
||||
// Check if previous navigation is possible
|
||||
export function canGoPrevious(book: UniversalReader): boolean {
|
||||
return book.position.currentPage > 1;
|
||||
}
|
||||
|
||||
// Get progress percentage
|
||||
export function getProgressPercentage(book: UniversalReader): number {
|
||||
return Math.round(book.position.progress * 100);
|
||||
}
|
||||
|
||||
// Update book position (after resize/recalculation)
|
||||
export function updatePosition(
|
||||
book: UniversalReader,
|
||||
newCFI?: string,
|
||||
): ReadingPosition {
|
||||
if (newCFI && book.pagination) {
|
||||
const pageNum = findPageByCFI(book.pagination, newCFI);
|
||||
return createPositionFromPage(book, pageNum);
|
||||
}
|
||||
|
||||
return book.position;
|
||||
}
|
||||
@@ -1,564 +0,0 @@
|
||||
// Import types
|
||||
import { UniversalReader } from "../../reader-shell";
|
||||
import type {
|
||||
SpineItem,
|
||||
SpineInfo,
|
||||
PageBoundary,
|
||||
PaginationData,
|
||||
PaginationSettings,
|
||||
ReadingPosition,
|
||||
} from "./types";
|
||||
|
||||
interface TextNodeInfo {
|
||||
node: Text;
|
||||
startChar: number; // Character position in HTML
|
||||
endChar: number; // Character position in HTML
|
||||
textStart: number; // Word position in plain text
|
||||
textEnd: number; // Word position in plain text
|
||||
}
|
||||
function buildTextNodeMapping(html: string): TextNodeInfo[] {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const textNodes: TextNodeInfo[] = [];
|
||||
|
||||
let htmlCharPos = 0;
|
||||
let textWordPos = 0;
|
||||
|
||||
function traverse(node: Node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || "";
|
||||
const words = countWords(text);
|
||||
|
||||
textNodes.push({
|
||||
node: node as Text,
|
||||
startChar: htmlCharPos,
|
||||
endChar: htmlCharPos + text.length,
|
||||
textStart: textWordPos,
|
||||
textEnd: textWordPos + words,
|
||||
});
|
||||
|
||||
textWordPos += words;
|
||||
htmlCharPos += text.length;
|
||||
} else {
|
||||
// For element nodes, just count the opening tag length
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const outerHTML = (node as Element).outerHTML;
|
||||
const tagEnd = outerHTML.indexOf(">") + 1;
|
||||
htmlCharPos += tagEnd;
|
||||
|
||||
// Recurse into children
|
||||
node.childNodes.forEach(traverse);
|
||||
|
||||
// Count closing tag
|
||||
const tagName = (node as Element).tagName;
|
||||
htmlCharPos += `</${tagName}>`.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doc.body.childNodes.forEach(traverse);
|
||||
return textNodes;
|
||||
}
|
||||
|
||||
function mapWordToHtmlChar(mapping: TextNodeInfo[], wordPos: number): number {
|
||||
for (const info of mapping) {
|
||||
if (wordPos >= info.textStart && wordPos <= info.textEnd) {
|
||||
// Word is in this text node
|
||||
const ratio =
|
||||
(wordPos - info.textStart) / (info.textEnd - info.textStart);
|
||||
return Math.round(
|
||||
info.startChar + ratio * (info.endChar - info.startChar),
|
||||
);
|
||||
}
|
||||
}
|
||||
return 0; // Fallback
|
||||
}
|
||||
|
||||
// Constants for word count estimation (from Kavita)
|
||||
const WORDS_PER_PAGE_BASE = 250; // At 16px font, 1.6 line height
|
||||
|
||||
// Calculate words per page based on settings
|
||||
function calculateWordsPerPage(settings: PaginationSettings): number {
|
||||
const fontSizeFactor = 16 / settings.fontSize;
|
||||
const lineHeightFactor = 1.6 / settings.lineHeight;
|
||||
const areaFactor =
|
||||
(settings.viewportWidth * settings.viewportHeight) / (800 * 600);
|
||||
|
||||
return Math.round(
|
||||
WORDS_PER_PAGE_BASE * fontSizeFactor * lineHeightFactor * areaFactor,
|
||||
);
|
||||
}
|
||||
|
||||
// Extract plain text from HTML
|
||||
function extractTextFromHTML(html: string): string {
|
||||
// Remove script and style tags
|
||||
const withoutScripts = html.replace(
|
||||
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
|
||||
"",
|
||||
);
|
||||
const withoutStyles = withoutScripts.replace(
|
||||
/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi,
|
||||
"",
|
||||
);
|
||||
|
||||
// Extract text content (simple version, no DOM)
|
||||
return withoutStyles
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Count words in text
|
||||
function countWords(text: string): number {
|
||||
return text
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 0).length;
|
||||
}
|
||||
|
||||
// Split text into word ranges for pages
|
||||
function splitIntoWordRanges(
|
||||
wordCount: number,
|
||||
wordsPerPage: number,
|
||||
): Array<{ start: number; end: number }> {
|
||||
const ranges: Array<{ start: number; end: number }> = [];
|
||||
let start = 0;
|
||||
|
||||
while (start < wordCount) {
|
||||
const end = Math.min(start + wordsPerPage, wordCount);
|
||||
ranges.push({ start, end });
|
||||
start = end;
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
// Escape special characters in CFI
|
||||
function escapeCFIString(str: string): string {
|
||||
return str
|
||||
.replace(/\[/g, "\\[")
|
||||
.replace(/\]/g, "\\]")
|
||||
.replace(/\(/g, "\\(")
|
||||
.replace(/\)/g, "\\)")
|
||||
.replace(/,/g, "\\,")
|
||||
.replace(/;/g, "\\;")
|
||||
.replace(/=/g, "\\=");
|
||||
}
|
||||
|
||||
// Generate EPUB CFI for a position in spine
|
||||
// Follows EPUB CFI spec: https://www.w3.org/TR/epub-cfi/
|
||||
// Format: epubcfi(/6/spine_index!/path/element/offset)
|
||||
function generateCFI(
|
||||
spineIndex: number,
|
||||
charOffset: number,
|
||||
totalChars: number,
|
||||
spineItemId: string,
|
||||
): string {
|
||||
const escapedId = spineItemId ? `[${escapeCFIString(spineItemId)}]` : "";
|
||||
const offset = Math.min(charOffset, totalChars);
|
||||
const spinePath = `/6/${spineIndex + 2}${escapedId}`;
|
||||
|
||||
return `epubcfi(${spinePath}!/4/2/1:${offset})`;
|
||||
}
|
||||
|
||||
// Parse EPUB CFI to extract position
|
||||
function parseCFI(
|
||||
cfi: string,
|
||||
): { spineIndex: number; charOffset: number } | null {
|
||||
if (!cfi.startsWith("epubcfi(")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove epubcfi( wrapper
|
||||
const inner = cfi.slice(8, -1);
|
||||
if (!inner) return null;
|
||||
|
||||
// Split on ! to separate spine path from content path
|
||||
const parts = inner.split("!");
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
// Extract spine index from /6/4 or /6/4[id] format
|
||||
const spineMatch = parts[0].match(/\/6\/(\d+)/);
|
||||
if (!spineMatch) return null;
|
||||
|
||||
const spineIndex = parseInt(spineMatch[1]) - 2; // Adjust for offset
|
||||
if (spineIndex < 0) return null;
|
||||
|
||||
// Extract character offset from :123 format
|
||||
const offsetMatch = parts[1].match(/:(\d+)$/);
|
||||
if (!offsetMatch) return null;
|
||||
|
||||
const charOffset = parseInt(offsetMatch[1]);
|
||||
|
||||
return { spineIndex, charOffset };
|
||||
}
|
||||
|
||||
// Calculate pagination for entire book
|
||||
export async function calculatePagination(
|
||||
spineItems: SpineItem[],
|
||||
contentMap: Map<string, Blob>,
|
||||
settings: PaginationSettings,
|
||||
): Promise<PaginationData> {
|
||||
const wordsPerPage = calculateWordsPerPage(settings);
|
||||
const spines: SpineInfo[] = [];
|
||||
const pageMap = new Map<number, PageBoundary>();
|
||||
let globalPageIndex = 0;
|
||||
|
||||
// Process each spine item
|
||||
for (let i = 0; i < spineItems.length; i++) {
|
||||
const spineItem = spineItems[i];
|
||||
|
||||
// Skip non-HTML items (cover pages, etc)
|
||||
if (spineItem.type !== "html") {
|
||||
spines.push({
|
||||
spineIndex: i,
|
||||
spineItemId: spineItem.id,
|
||||
content: "",
|
||||
charCount: 0,
|
||||
wordCount: 0,
|
||||
cfiStart: "",
|
||||
pages: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get content
|
||||
const contentBlob = contentMap.get(spineItem.content);
|
||||
if (!contentBlob) {
|
||||
console.warn(`Content not found for spine ${spineItem.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentHTML = await contentBlob.text();
|
||||
const plainText = extractTextFromHTML(contentHTML);
|
||||
const wordCount = countWords(plainText);
|
||||
const charCount = plainText.length;
|
||||
|
||||
// Skip empty spines
|
||||
if (wordCount === 0) {
|
||||
spines.push({
|
||||
spineIndex: i,
|
||||
spineItemId: spineItem.id,
|
||||
content: contentHTML,
|
||||
charCount,
|
||||
wordCount,
|
||||
cfiStart: generateCFI(i, 0, charCount, spineItem.id),
|
||||
pages: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split into pages based on TEXT content, not HTML
|
||||
const wordRanges = splitIntoWordRanges(wordCount, wordsPerPage);
|
||||
const pages: PageBoundary[] = [];
|
||||
// Build a map of word positions to HTML character positions
|
||||
const textNodeInfo = buildTextNodeMapping(contentHTML);
|
||||
for (let j = 0; j < wordRanges.length; j++) {
|
||||
const range = wordRanges[j];
|
||||
|
||||
// Map word positions to ACTUAL HTML character positions
|
||||
const htmlCharStart = mapWordToHtmlChar(textNodeInfo, range.start);
|
||||
const htmlCharEnd = mapWordToHtmlChar(textNodeInfo, range.end);
|
||||
|
||||
const page: PageBoundary = {
|
||||
pageIndex: globalPageIndex,
|
||||
localPageIndex: j,
|
||||
charStart: htmlCharStart,
|
||||
charEnd: htmlCharEnd,
|
||||
wordStart: range.start,
|
||||
wordEnd: range.end,
|
||||
cfi: generateCFI(i, htmlCharStart, charCount, spineItem.id),
|
||||
};
|
||||
|
||||
pages.push(page);
|
||||
pageMap.set(globalPageIndex, page);
|
||||
globalPageIndex++;
|
||||
}
|
||||
|
||||
spines.push({
|
||||
spineIndex: i,
|
||||
spineItemId: spineItem.id,
|
||||
content: contentHTML,
|
||||
charCount,
|
||||
wordCount,
|
||||
cfiStart: generateCFI(i, 0, charCount, spineItem.id),
|
||||
pages,
|
||||
});
|
||||
}
|
||||
|
||||
// Build map
|
||||
const spineMap = new Map<number, SpineInfo>();
|
||||
for (const spine of spines) {
|
||||
spineMap.set(spine.spineIndex, spine);
|
||||
}
|
||||
|
||||
return {
|
||||
totalPages: globalPageIndex,
|
||||
spines,
|
||||
spineMap,
|
||||
pageMap,
|
||||
calculatedAt: Date.now(),
|
||||
settings: { ...settings, wordsPerPage },
|
||||
};
|
||||
}
|
||||
|
||||
// Find which page contains a CFI
|
||||
export function findPageByCFI(
|
||||
pagination: PaginationData,
|
||||
targetCFI: string,
|
||||
): number {
|
||||
const parsed = parseCFI(targetCFI);
|
||||
if (!parsed) return 1;
|
||||
|
||||
const { spineIndex, charOffset } = parsed;
|
||||
const spine = pagination.spineMap.get(spineIndex);
|
||||
|
||||
if (!spine || spine.pages.length === 0) return 1;
|
||||
|
||||
// Find page containing this character offset
|
||||
for (const page of spine.pages) {
|
||||
if (charOffset >= page.charStart && charOffset < page.charEnd) {
|
||||
return page.pageIndex + 1; // 1-indexed
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Extract HTML slice between character offsets
|
||||
function extractHTMLSlice(
|
||||
html: string,
|
||||
charStart: number,
|
||||
charEnd: number,
|
||||
): string {
|
||||
if (charStart === 0 && charEnd >= html.length) {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Parse HTML and extract text nodes within the character range
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const body = doc.body;
|
||||
|
||||
// Find all text nodes and their cumulative character counts
|
||||
type TextNodeInfo = { node: Text; startChar: number; endChar: number };
|
||||
const textNodes: TextNodeInfo[] = [];
|
||||
let cumulativeChars = 0;
|
||||
|
||||
function traverse(node: Node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || "";
|
||||
const startChar = cumulativeChars;
|
||||
cumulativeChars += text.length;
|
||||
const endChar = cumulativeChars;
|
||||
|
||||
textNodes.push({ node: node as Text, startChar, endChar });
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
// Skip script and style tags
|
||||
if (node instanceof HTMLElement) {
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
if (tagName === "script" || tagName === "style") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively traverse children
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(body);
|
||||
|
||||
// Find which text nodes intersect with the requested range
|
||||
const relevantNodes: { node: Text; before: string; after: string }[] = [];
|
||||
|
||||
for (const { node, startChar, endChar } of textNodes) {
|
||||
if (endChar <= charStart || startChar >= charEnd) {
|
||||
// No overlap
|
||||
continue;
|
||||
}
|
||||
const text = node.textContent || "";
|
||||
let resultText = text;
|
||||
// Trim from left if node starts before page
|
||||
if (startChar < charStart) {
|
||||
resultText = text.substring(charStart - startChar);
|
||||
}
|
||||
// Trim from right if node extends past page end
|
||||
if (endChar > charEnd) {
|
||||
// Calculate where to cut within the (potentially already trimmed) text
|
||||
const cutPosition = charEnd - startChar;
|
||||
resultText = text.substring(0, cutPosition);
|
||||
}
|
||||
// Handle case where both trims are needed
|
||||
if (startChar < charStart && endChar > charEnd) {
|
||||
const leftTrim = charStart - startChar;
|
||||
const rightTrim = endChar - charEnd;
|
||||
resultText = text.substring(leftTrim, text.length - rightTrim);
|
||||
}
|
||||
relevantNodes.push({ node, before: "", after: resultText });
|
||||
}
|
||||
|
||||
// Preserve original HTML structure for nodes in range
|
||||
const startNode = textNodes.find((n) => n.endChar > charStart);
|
||||
const endNode = textNodes.find((n) => n.startChar < charEnd);
|
||||
|
||||
if (!startNode || !endNode) {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Find element boundaries
|
||||
let startElement: Node | null = startNode.node;
|
||||
while (startElement && startElement.parentNode !== body) {
|
||||
startElement = startElement.parentNode;
|
||||
}
|
||||
|
||||
let endElement: Node | null = endNode.node;
|
||||
while (endElement && endElement.parentNode !== body) {
|
||||
endElement = endElement.parentNode;
|
||||
}
|
||||
|
||||
// Extract and modify the relevant portion
|
||||
if (startElement && endElement) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
let currentElement: Node | null = startElement;
|
||||
let foundEnd = false;
|
||||
|
||||
while (currentElement && !foundEnd) {
|
||||
if (currentElement.nodeType === Node.ELEMENT_NODE) {
|
||||
const clone = (currentElement as Element).cloneNode(false);
|
||||
fragment.appendChild(clone);
|
||||
|
||||
// Process children
|
||||
for (const child of Array.from(currentElement.childNodes)) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const textNodeInfo = textNodes.find((n) => n.node === child);
|
||||
if (textNodeInfo) {
|
||||
const modified = document.createTextNode(
|
||||
relevantNodes.find((n) => n.node === child)?.after || "",
|
||||
);
|
||||
clone.appendChild(modified);
|
||||
}
|
||||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
// Recursively handle element children
|
||||
const childClone = child.cloneNode(true);
|
||||
clone.appendChild(childClone);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentElement === endElement) {
|
||||
foundEnd = true;
|
||||
}
|
||||
}
|
||||
|
||||
currentElement = currentElement.nextSibling;
|
||||
}
|
||||
|
||||
// Serialize fragment back to HTML
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.appendChild(fragment);
|
||||
return tempDiv.innerHTML;
|
||||
}
|
||||
|
||||
// Fallback: return original HTML if extraction fails
|
||||
return html;
|
||||
}
|
||||
|
||||
// Get page content (HTML slice for a page)
|
||||
export function getPageContent(
|
||||
pagination: PaginationData,
|
||||
pageIndex: number,
|
||||
): string {
|
||||
const page = pagination.pageMap.get(pageIndex);
|
||||
if (!page) return "";
|
||||
|
||||
// Find the spine that contains this page
|
||||
// Pages are stored in order, so we can find the spine by checking which pages it contains
|
||||
let spine: SpineInfo | undefined;
|
||||
for (const s of pagination.spines) {
|
||||
if (s.pages.some((p) => p.pageIndex === pageIndex)) {
|
||||
spine = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!spine) return "";
|
||||
|
||||
// Extract HTML content between page boundaries
|
||||
const htmlSlice = extractHTMLSlice(
|
||||
spine.content,
|
||||
page.charStart,
|
||||
page.charEnd,
|
||||
);
|
||||
|
||||
// Wrap in a div to ensure valid HTML structure
|
||||
return `<div class="page-content-wrapper">${htmlSlice}</div>`;
|
||||
}
|
||||
|
||||
// Recalculate pagination on viewport change
|
||||
export function shouldRecalculate(
|
||||
pagination: PaginationData | null,
|
||||
newSettings: PaginationSettings,
|
||||
): boolean {
|
||||
if (!pagination) return true;
|
||||
|
||||
const sizeChanged =
|
||||
Math.abs(pagination.settings.viewportWidth - newSettings.viewportWidth) >
|
||||
50 ||
|
||||
Math.abs(pagination.settings.viewportHeight - newSettings.viewportHeight) >
|
||||
50;
|
||||
|
||||
const fontChanged = pagination.settings.fontSize !== newSettings.fontSize;
|
||||
const lineChanged = pagination.settings.lineHeight !== newSettings.lineHeight;
|
||||
|
||||
return sizeChanged || fontChanged || lineChanged;
|
||||
}
|
||||
|
||||
// Create position object from page number
|
||||
export function createPositionFromPage(
|
||||
book: UniversalReader,
|
||||
page: number,
|
||||
): ReadingPosition {
|
||||
if (!book.pagination) {
|
||||
return {
|
||||
currentPage: 1,
|
||||
spineIndex: 0,
|
||||
localPageIndex: 0,
|
||||
cfi: "",
|
||||
progress: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const pageIndex = page - 1;
|
||||
const pageData = book.pagination.pageMap.get(pageIndex);
|
||||
|
||||
if (!pageData) {
|
||||
return {
|
||||
currentPage: 1,
|
||||
spineIndex: 0,
|
||||
localPageIndex: 0,
|
||||
cfi: "",
|
||||
progress: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Find which spine this page belongs to
|
||||
let spineIndex = 0;
|
||||
for (const spine of book.pagination.spines) {
|
||||
if (pageData.localPageIndex < spine.pages.length) {
|
||||
spineIndex = spine.spineIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentPage: page,
|
||||
spineIndex,
|
||||
localPageIndex: pageData.localPageIndex,
|
||||
cfi: pageData.cfi,
|
||||
progress:
|
||||
book.pagination.totalPages > 0 ? page / book.pagination.totalPages : 0,
|
||||
};
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
// Import types and existing parsers
|
||||
import type { TOCItem } from "./types";
|
||||
import { parseEPUB } from "../../parsers/epub-parsers";
|
||||
import { parseFB2 } from "../../parsers/fb2-parser";
|
||||
import { parseTXT } from "../../parsers/txt-parser";
|
||||
import { parseHTML } from "../../parsers/html-parser";
|
||||
import { UniversalReader } from "../../reader-shell";
|
||||
|
||||
// Parse any reflowable format
|
||||
export async function parseReflowable(
|
||||
file: File,
|
||||
format: "epub" | "fb2" | "txt" | "html",
|
||||
): Promise<UniversalReader> {
|
||||
switch (format) {
|
||||
case "epub": {
|
||||
const cif = await parseEPUB(file); // Get EbookCIF
|
||||
return {
|
||||
// Wrap in UniversalReader structure
|
||||
type: "ebook",
|
||||
cif, // ← The EbookCIF goes here
|
||||
currentSpineIndex: 0,
|
||||
currentPage: 1,
|
||||
pagination: null,
|
||||
position: {
|
||||
currentPage: 1,
|
||||
spineIndex: 0,
|
||||
localPageIndex: 0,
|
||||
cfi: "",
|
||||
progress: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "fb2": {
|
||||
const cif = await parseFB2(file);
|
||||
return {
|
||||
type: "ebook",
|
||||
cif,
|
||||
currentSpineIndex: 0,
|
||||
currentPage: 1,
|
||||
pagination: null,
|
||||
position: {
|
||||
currentPage: 1,
|
||||
spineIndex: 0,
|
||||
localPageIndex: 0,
|
||||
cfi: "",
|
||||
progress: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "txt": {
|
||||
const cif = await parseTXT(file);
|
||||
return {
|
||||
type: "ebook",
|
||||
cif,
|
||||
currentSpineIndex: 0,
|
||||
currentPage: 1,
|
||||
pagination: null,
|
||||
position: {
|
||||
currentPage: 1,
|
||||
spineIndex: 0,
|
||||
localPageIndex: 0,
|
||||
cfi: "",
|
||||
progress: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "html": {
|
||||
const cif = await parseHTML(file);
|
||||
return {
|
||||
type: "ebook",
|
||||
cif,
|
||||
currentSpineIndex: 0,
|
||||
currentPage: 1,
|
||||
pagination: null,
|
||||
position: {
|
||||
currentPage: 1,
|
||||
spineIndex: 0,
|
||||
localPageIndex: 0,
|
||||
cfi: "",
|
||||
progress: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported reflowable format: ${format}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate parsed book data
|
||||
export function validateBook(book: UniversalReader): boolean {
|
||||
return book.cif.spine.length > 0 && book.cif.metadata.title !== "";
|
||||
}
|
||||
|
||||
// Get book title
|
||||
export function getBookTitle(book: UniversalReader): string {
|
||||
return book.cif.metadata.title || "Untitled";
|
||||
}
|
||||
|
||||
// Get book author
|
||||
export function getBookAuthor(book: UniversalReader): string {
|
||||
return book.cif.metadata.author || "Unknown";
|
||||
}
|
||||
|
||||
// Get total spine count
|
||||
export function getSpineCount(book: UniversalReader): number {
|
||||
return book.cif.spine.length;
|
||||
}
|
||||
|
||||
// Get TOC as flat list
|
||||
export function getFlatTOC(book: UniversalReader): TOCItem[] {
|
||||
const flat: TOCItem[] = [];
|
||||
|
||||
function traverse(items: TOCItem[]) {
|
||||
for (const item of items) {
|
||||
flat.push(item);
|
||||
if (item.children.length > 0) {
|
||||
traverse(item.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(book.cif.toc);
|
||||
return flat;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// Import types
|
||||
import type { ReadingPosition } from "./types";
|
||||
import { findPageByCFI, createPositionFromPage } from "./page-calculator";
|
||||
import { UniversalReader } from "../../reader-shell";
|
||||
|
||||
// Update current position
|
||||
export function updateCurrentPosition(
|
||||
book: UniversalReader,
|
||||
position: ReadingPosition,
|
||||
): UniversalReader {
|
||||
return {
|
||||
...book,
|
||||
position,
|
||||
currentSpineIndex: position.spineIndex,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract CFI from position
|
||||
export function getCurrentCFI(book: UniversalReader): string {
|
||||
return book.position.cfi;
|
||||
}
|
||||
|
||||
// Calculate progress for display
|
||||
export function calculateProgress(book: UniversalReader): {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
percentage: number;
|
||||
} {
|
||||
const totalPages = book.pagination?.totalPages || 1;
|
||||
const currentPage = book.position.currentPage;
|
||||
const percentage =
|
||||
totalPages > 0 ? Math.round((currentPage / totalPages) * 100) : 0;
|
||||
|
||||
return { currentPage, totalPages, percentage };
|
||||
}
|
||||
|
||||
// Get position for saving to database
|
||||
export function getPositionForSave(book: UniversalReader): {
|
||||
cfi: string;
|
||||
progress: number;
|
||||
page: number;
|
||||
} {
|
||||
return {
|
||||
cfi: book.position.cfi,
|
||||
progress: book.position.progress,
|
||||
page: book.position.currentPage,
|
||||
};
|
||||
}
|
||||
|
||||
// Restore position from database
|
||||
export function restorePosition(
|
||||
book: UniversalReader,
|
||||
savedCFI: string,
|
||||
savedPage?: number,
|
||||
): ReadingPosition {
|
||||
if (!book.pagination) {
|
||||
return book.position;
|
||||
}
|
||||
|
||||
// If we have saved CFI, try to find exact position
|
||||
if (savedCFI) {
|
||||
const pageNum = findPageByCFI(book.pagination, savedCFI);
|
||||
return createPositionFromPage(book, pageNum);
|
||||
}
|
||||
|
||||
// Otherwise use saved page number
|
||||
if (savedPage && savedPage > 0) {
|
||||
return createPositionFromPage(book, savedPage);
|
||||
}
|
||||
|
||||
return book.position;
|
||||
}
|
||||
|
||||
// Check if position changed significantly
|
||||
export function didPositionChange(
|
||||
oldPos: ReadingPosition,
|
||||
newPos: ReadingPosition,
|
||||
): boolean {
|
||||
return (
|
||||
oldPos.currentPage !== newPos.currentPage ||
|
||||
oldPos.cfi !== newPos.cfi ||
|
||||
Math.abs(oldPos.progress - newPos.progress) > 0.01
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// Spine item structure from parsed EPUB/FB2/etc
|
||||
export interface SpineItem {
|
||||
id: string;
|
||||
type: "html" | "image" | "other";
|
||||
content: string; // Blob URL or content ID
|
||||
href?: string; // For CFI generation
|
||||
}
|
||||
|
||||
// Information about a single spine item
|
||||
export interface SpineInfo {
|
||||
spineIndex: number;
|
||||
spineItemId: string;
|
||||
content: string; // Full HTML content
|
||||
charCount: number; // Total characters
|
||||
wordCount: number; // Total words (for pagination)
|
||||
cfiStart: string; // CFI at start of this spine
|
||||
pages: PageBoundary[]; // Page boundaries within this spine
|
||||
}
|
||||
|
||||
// A single page boundary within a spine
|
||||
export interface PageBoundary {
|
||||
pageIndex: number; // Global page index
|
||||
localPageIndex: number; // Page index within this spine
|
||||
charStart: number; // Character offset from start of spine
|
||||
charEnd: number; // Character offset at end of page
|
||||
wordStart: number; // Word offset from start of spine
|
||||
wordEnd: number; // Word offset at end of page
|
||||
cfi: string; // CFI for this position
|
||||
}
|
||||
|
||||
// Complete pagination data
|
||||
export interface PaginationData {
|
||||
totalPages: number;
|
||||
spines: SpineInfo[];
|
||||
spineMap: Map<number, SpineInfo>;
|
||||
pageMap: Map<number, PageBoundary>; // pageIndex -> PageBoundary
|
||||
calculatedAt: number;
|
||||
settings: PaginationSettings;
|
||||
}
|
||||
|
||||
// Settings used for calculation
|
||||
export interface PaginationSettings {
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
wordsPerPage: number; // Calculated from above
|
||||
}
|
||||
|
||||
// Current reading position
|
||||
export interface ReadingPosition {
|
||||
currentPage: number;
|
||||
spineIndex: number;
|
||||
localPageIndex: number;
|
||||
cfi: string;
|
||||
progress: number; // 0-1
|
||||
}
|
||||
|
||||
// Reflowable book data
|
||||
export interface ReflowableBook {
|
||||
type: "epub" | "fb2" | "txt" | "html";
|
||||
spine: SpineItem[];
|
||||
resources: Map<string, Blob>;
|
||||
toc: TOCItem[];
|
||||
metadata: BookMetadata;
|
||||
pagination: PaginationData | null;
|
||||
position: ReadingPosition;
|
||||
}
|
||||
|
||||
// Table of contents item
|
||||
export interface TOCItem {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
children: TOCItem[];
|
||||
}
|
||||
|
||||
// Book metadata
|
||||
export interface BookMetadata {
|
||||
title: string;
|
||||
author: string;
|
||||
identifier: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
// EPUB Parser - Converts EPUB 2/3 to Common Intermediate Format
|
||||
// Procedural style: Functions, not classes
|
||||
|
||||
import JSZip from "jszip";
|
||||
|
||||
// ============================================================
|
||||
// Main Parse Function
|
||||
// ============================================================
|
||||
|
||||
export async function parseEPUB(epubBlob: Blob): Promise<EbookCIF> {
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = await JSZip.loadAsync(epubBlob);
|
||||
|
||||
// Parse container.xml to find OPF file
|
||||
const containerXml = await getZipFileContent(zip, "META-INF/container.xml");
|
||||
const opfPath = extractOPFPath(containerXml);
|
||||
|
||||
if (!opfPath) {
|
||||
throw new Error("Invalid EPUB: no OPF file found");
|
||||
}
|
||||
|
||||
const opfXml = await getZipFileContent(zip, opfPath);
|
||||
const packageDoc = parseXML(opfXml);
|
||||
|
||||
const metadata = extractMetadata(packageDoc);
|
||||
const spine = parseSpine(packageDoc, opfPath);
|
||||
const toc = await parseTOC(zip, packageDoc, opfPath);
|
||||
const resources = await loadResources(zip);
|
||||
const coverImage = await extractCover(zip, packageDoc);
|
||||
|
||||
// Calculate locations (minimal - backend handles detailed tracking)
|
||||
const totalCharacters = await calculateTotalCharacters(spine, resources);
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
...metadata,
|
||||
coverImage,
|
||||
},
|
||||
toc,
|
||||
spine,
|
||||
resources,
|
||||
locations: {
|
||||
totalCharacters,
|
||||
estimatedPages: Math.ceil(totalCharacters / 1500),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helper Functions
|
||||
// ============================================================
|
||||
|
||||
async function getZipFileContent(zip: any, path: string): Promise<string> {
|
||||
const file = zip.file(path);
|
||||
if (!file) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
return await file.async("text");
|
||||
}
|
||||
|
||||
function parseXML(xmlString: string): XMLDocument {
|
||||
const parser = new DOMParser();
|
||||
return parser.parseFromString(xmlString, "text/xml");
|
||||
}
|
||||
|
||||
function extractOPFPath(containerXml: string): string | null {
|
||||
const containerDoc = parseXML(containerXml);
|
||||
return (
|
||||
containerDoc.querySelector("rootfile")?.getAttribute("full-path") || null
|
||||
);
|
||||
}
|
||||
|
||||
function extractMetadata(packageDoc: XMLDocument): EbookCIF["metadata"] {
|
||||
const metadata = packageDoc.querySelector("metadata");
|
||||
if (!metadata) {
|
||||
throw new Error("No metadata found in OPF");
|
||||
}
|
||||
|
||||
return {
|
||||
title: metadata.querySelector("title")?.textContent || "",
|
||||
author: metadata.querySelector("creator")?.textContent || "",
|
||||
language: metadata.querySelector("language")?.textContent || "en",
|
||||
publisher: metadata.querySelector("publisher")?.textContent || undefined,
|
||||
isbn: metadata.querySelector("identifier")?.textContent || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSpine(
|
||||
packageDoc: XMLDocument,
|
||||
opfPath: string,
|
||||
): EbookCIF["spine"] {
|
||||
const spine = packageDoc.querySelector("spine");
|
||||
const manifest = packageDoc.querySelector("manifest");
|
||||
if (!spine || !manifest) {
|
||||
throw new Error("No spine or manifest found in OPF");
|
||||
}
|
||||
const spineItems = spine.querySelectorAll("itemref");
|
||||
const result: EbookCIF["spine"] = [];
|
||||
spineItems.forEach((itemref) => {
|
||||
const idref = itemref.getAttribute("idref");
|
||||
console.log("Spine item idref:", idref); // Your debug log - keep or remove
|
||||
if (!idref) return;
|
||||
const manifestItem = manifest.querySelector(`[id="${idref}"]`);
|
||||
console.log("Manifest item:", manifestItem); // Your debug log
|
||||
if (!manifestItem) return;
|
||||
const href = manifestItem.getAttribute("href");
|
||||
console.log("Href:", href); // Your debug log
|
||||
if (!href) return;
|
||||
const resolvedPath = resolvePath(opfPath, href);
|
||||
result.push({
|
||||
id: idref,
|
||||
type: "html" as const,
|
||||
content: resolvedPath,
|
||||
properties: (itemref.getAttribute("properties") || "")
|
||||
.split(" ")
|
||||
.filter(Boolean),
|
||||
index: result.length,
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function parseTOC(
|
||||
zip: any,
|
||||
packageDoc: XMLDocument,
|
||||
opfPath: string,
|
||||
): Promise<EbookCIF["toc"]> {
|
||||
// Try EPUB 3.0 navigation document first
|
||||
const navItem = packageDoc.querySelector('manifest item[properties~="nav"]');
|
||||
if (navItem) {
|
||||
const navHref = navItem.getAttribute("href");
|
||||
if (navHref) {
|
||||
const navPath = resolvePath(opfPath, navHref);
|
||||
return parseNavTOC(zip, navPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to EPUB 2.0 NCX
|
||||
const spine = packageDoc.querySelector("spine");
|
||||
const ncxId = spine?.getAttribute("toc");
|
||||
if (ncxId) {
|
||||
const ncxItem = packageDoc.querySelector(`manifest [id="${ncxId}"]`);
|
||||
if (ncxItem) {
|
||||
const ncxHref = ncxItem.getAttribute("href");
|
||||
if (ncxHref) {
|
||||
const ncxPath = resolvePath(opfPath, ncxHref);
|
||||
return parseNCXTOC(zip, ncxPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function parseNavTOC(
|
||||
zip: any,
|
||||
navPath: string,
|
||||
): Promise<EbookCIF["toc"]> {
|
||||
const navXml = await getZipFileContent(zip, navPath);
|
||||
const navDoc = parseXML(navXml);
|
||||
const nav = navDoc.querySelector("nav");
|
||||
|
||||
if (!nav) return [];
|
||||
|
||||
const ol = nav.querySelector("ol");
|
||||
if (!ol) return [];
|
||||
|
||||
const items = ol.querySelectorAll(":scope > li");
|
||||
const result: EbookCIF["toc"] = [];
|
||||
|
||||
for (const li of Array.from(items)) {
|
||||
const link = li.querySelector("a");
|
||||
if (link) {
|
||||
result.push({
|
||||
id: link.getAttribute("href") || "",
|
||||
title: link.textContent || "",
|
||||
href: link.getAttribute("href") || "",
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function parseNCXTOC(
|
||||
zip: any,
|
||||
ncxPath: string,
|
||||
): Promise<EbookCIF["toc"]> {
|
||||
const ncxXml = await getZipFileContent(zip, ncxPath);
|
||||
const ncxDoc = parseXML(ncxXml);
|
||||
const navMap = ncxDoc.querySelector("navMap");
|
||||
|
||||
if (!navMap) return [];
|
||||
|
||||
return parseNCXNode(navMap);
|
||||
}
|
||||
|
||||
function parseNCXNode(node: Element): EbookCIF["toc"] {
|
||||
const navPoints = node.querySelectorAll(":scope > navPoint");
|
||||
const result: EbookCIF["toc"] = [];
|
||||
|
||||
navPoints.forEach((navPoint) => {
|
||||
const label = navPoint.querySelector("navLabel text")?.textContent || "";
|
||||
const content = navPoint.querySelector("content");
|
||||
const href = content?.getAttribute("src") || "";
|
||||
|
||||
result.push({
|
||||
id: href,
|
||||
title: label,
|
||||
href,
|
||||
children: parseNCXNode(navPoint),
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadResources(zip: any): Promise<Map<string, Blob>> {
|
||||
const resources = new Map<string, Blob>();
|
||||
const files = Object.keys(zip.files);
|
||||
|
||||
for (const path of files) {
|
||||
const file = zip.file(path);
|
||||
if (file && !file.dir) {
|
||||
const blob = await file.async("blob");
|
||||
|
||||
// Store with full path (e.g., "OEBPS/image/1.png")
|
||||
resources.set(path, blob);
|
||||
|
||||
// Store with filename only (e.g., "1.png")
|
||||
const filename = path.split("/").pop();
|
||||
if (filename && filename !== path) {
|
||||
if (!resources.has(filename)) {
|
||||
resources.set(filename, blob);
|
||||
}
|
||||
}
|
||||
|
||||
// Store with relative path (everything after first /)
|
||||
// e.g., "OEBPS/image/1.png" -> "image/1.png"
|
||||
const firstSlash = path.indexOf("/");
|
||||
if (firstSlash > 0) {
|
||||
const relativePath = path.substring(firstSlash + 1);
|
||||
if (!resources.has(relativePath)) {
|
||||
resources.set(relativePath, blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
async function extractCover(
|
||||
zip: any,
|
||||
packageDoc: XMLDocument,
|
||||
): Promise<Blob | undefined> {
|
||||
// Try cover-id metadata
|
||||
const coverId = packageDoc
|
||||
.querySelector('meta[name="cover"]')
|
||||
?.getAttribute("content");
|
||||
if (coverId) {
|
||||
const coverItem = packageDoc.querySelector(`manifest [id="${coverId}"]`);
|
||||
if (coverItem) {
|
||||
const coverHref = coverItem.getAttribute("href");
|
||||
if (coverHref) {
|
||||
const coverFile = zip.file(coverHref);
|
||||
if (coverFile) {
|
||||
return await coverFile.async("blob");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: look for cover image in manifest
|
||||
const coverItem = packageDoc.querySelector(
|
||||
'manifest item[properties~="cover-image"]',
|
||||
);
|
||||
if (coverItem) {
|
||||
const coverHref = coverItem.getAttribute("href");
|
||||
if (coverHref) {
|
||||
const coverFile = zip.file(coverHref);
|
||||
if (coverFile) {
|
||||
return await coverFile.async("blob");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolvePath(basePath: string, relativePath: string): string {
|
||||
const baseDir = basePath.substring(0, basePath.lastIndexOf("/") + 1);
|
||||
return baseDir + relativePath;
|
||||
}
|
||||
|
||||
async function calculateTotalCharacters(
|
||||
spine: EbookCIF["spine"],
|
||||
resources: Map<string, Blob>,
|
||||
): Promise<number> {
|
||||
let total = 0;
|
||||
|
||||
for (const item of spine) {
|
||||
if (item.type === "html") {
|
||||
const content = resources.get(item.content);
|
||||
if (content) {
|
||||
const text = await content.text();
|
||||
total += text.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Metadata Quick Extract (for library view)
|
||||
// ============================================================
|
||||
|
||||
export async function extractEPUBMetadata(
|
||||
epubBlob: Blob,
|
||||
): Promise<Partial<EbookCIF["metadata"]>> {
|
||||
const zip = await JSZip.loadAsync(epubBlob);
|
||||
|
||||
const containerXml = await getZipFileContent(zip, "META-INF/container.xml");
|
||||
const opfPath = extractOPFPath(containerXml);
|
||||
|
||||
if (!opfPath) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const opfXml = await getZipFileContent(zip, opfPath);
|
||||
const packageDoc = parseXML(opfXml);
|
||||
|
||||
return extractMetadata(packageDoc);
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
// FB2 Parser - Converts FictionBook 2 to Common Intermediate Format
|
||||
// FB2 is XML-based, similar to EPUB structure
|
||||
// Procedural style: Functions, not classes
|
||||
|
||||
// ============================================================
|
||||
// Main Parse Function
|
||||
// ============================================================
|
||||
|
||||
export async function parseFB2(fb2Blob: Blob): Promise<EbookCIF> {
|
||||
// FB2 can be plain XML or zipped (.fb2.zip)
|
||||
let xmlContent: string;
|
||||
|
||||
if (
|
||||
fb2Blob.type === "application/zip" ||
|
||||
fb2Blob.type === "application/x-zip-compressed"
|
||||
) {
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = await JSZip.loadAsync(fb2Blob);
|
||||
const files = Object.keys(zip.files);
|
||||
|
||||
// Find the first .fb2 file in the zip
|
||||
const fb2File = files.find((f) => f.endsWith(".fb2"));
|
||||
if (!fb2File) {
|
||||
throw new Error("No .fb2 file found in archive");
|
||||
}
|
||||
|
||||
xmlContent = await zip.file(fb2File)!.async("text");
|
||||
} else {
|
||||
xmlContent = await fb2Blob.text();
|
||||
}
|
||||
|
||||
const xmlDoc = parseXML(xmlContent);
|
||||
|
||||
const metadata = extractFB2Metadata(xmlDoc);
|
||||
const toc = parseFB2TOC(xmlDoc);
|
||||
const spine = createFB2Spine(xmlDoc);
|
||||
const resources = await extractFB2Resources(xmlDoc, fb2Blob);
|
||||
|
||||
// Calculate locations (minimal - backend handles detailed tracking)
|
||||
const totalCharacters = calculateFB2Characters(xmlDoc);
|
||||
|
||||
return {
|
||||
metadata,
|
||||
toc,
|
||||
spine,
|
||||
resources,
|
||||
locations: {
|
||||
totalCharacters,
|
||||
estimatedPages: Math.ceil(totalCharacters / 1500),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helper Functions
|
||||
// ============================================================
|
||||
|
||||
function parseXML(xmlString: string): XMLDocument {
|
||||
const parser = new DOMParser();
|
||||
return parser.parseFromString(xmlString, "text/xml");
|
||||
}
|
||||
|
||||
function extractFB2Metadata(xmlDoc: XMLDocument): EbookCIF["metadata"] {
|
||||
const titleInfo = xmlDoc.querySelector("title-info");
|
||||
const documentInfo = xmlDoc.querySelector("document-info");
|
||||
|
||||
if (!titleInfo) {
|
||||
throw new Error("Invalid FB2: no title-info found");
|
||||
}
|
||||
|
||||
return {
|
||||
title: titleInfo.querySelector("book-title")?.textContent || "",
|
||||
author: extractFB2Author(titleInfo),
|
||||
language: titleInfo.querySelector("lang")?.textContent || "en",
|
||||
publisher:
|
||||
documentInfo?.querySelector("publisher")?.textContent || undefined,
|
||||
isbn: undefined, // FB2 doesn't typically have ISBN
|
||||
};
|
||||
}
|
||||
|
||||
function extractFB2Author(titleInfo: Element): string {
|
||||
const author = titleInfo.querySelector("author");
|
||||
if (!author) return "";
|
||||
|
||||
const firstName = author.querySelector("first-name")?.textContent || "";
|
||||
const lastName = author.querySelector("last-name")?.textContent || "";
|
||||
const middleName = author.querySelector("middle-name")?.textContent || "";
|
||||
|
||||
const parts = [firstName, middleName, lastName].filter(Boolean);
|
||||
return parts.join(" ") || "Unknown";
|
||||
}
|
||||
|
||||
function parseFB2TOC(xmlDoc: XMLDocument): EbookCIF["toc"] {
|
||||
const toc: EbookCIF["toc"] = [];
|
||||
const body = xmlDoc.querySelector("body");
|
||||
|
||||
if (!body) return toc;
|
||||
|
||||
const sections = body.querySelectorAll(":scope > section");
|
||||
let sectionIndex = 0;
|
||||
|
||||
for (const section of sections) {
|
||||
const title = section.querySelector("title");
|
||||
const titleText =
|
||||
title?.textContent.trim() || `Section ${sectionIndex + 1}`;
|
||||
|
||||
toc.push({
|
||||
id: `section-${sectionIndex}`,
|
||||
title: titleText,
|
||||
href: `#section-${sectionIndex}`,
|
||||
children: [],
|
||||
});
|
||||
|
||||
sectionIndex++;
|
||||
}
|
||||
|
||||
return toc;
|
||||
}
|
||||
|
||||
function createFB2Spine(xmlDoc: XMLDocument): EbookCIF["spine"] {
|
||||
const spine: EbookCIF["spine"] = [];
|
||||
const body = xmlDoc.querySelector("body");
|
||||
|
||||
if (!body) return spine;
|
||||
|
||||
// Convert each section to HTML
|
||||
const sections = body.querySelectorAll(":scope > section");
|
||||
|
||||
sections.forEach((section, index) => {
|
||||
const htmlContent = convertFB2SectionToHTML(section, index);
|
||||
|
||||
spine.push({
|
||||
id: `section-${index}`,
|
||||
type: "html",
|
||||
content: htmlContent,
|
||||
index,
|
||||
});
|
||||
});
|
||||
|
||||
return spine;
|
||||
}
|
||||
|
||||
function convertFB2SectionToHTML(section: Element, index: number): string {
|
||||
const title = section.querySelector("title");
|
||||
let html = `<div id="section-${index}" class="fb2-section">`;
|
||||
|
||||
if (title) {
|
||||
html += `<h1>${title.textContent}</h1>`;
|
||||
}
|
||||
|
||||
// Convert paragraphs
|
||||
const paragraphs = section.querySelectorAll("p");
|
||||
paragraphs.forEach((p) => {
|
||||
html += `<p>${p.innerHTML}</p>`;
|
||||
});
|
||||
|
||||
// Convert images
|
||||
const images = section.querySelectorAll("image");
|
||||
images.forEach((img) => {
|
||||
const href = img.getAttribute("l:href");
|
||||
const alt = img.getAttribute("alt") || "";
|
||||
if (href) {
|
||||
html += `<img src="${href}" alt="${alt}" />`;
|
||||
}
|
||||
});
|
||||
|
||||
html += "</div>";
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
async function extractFB2Resources(
|
||||
xmlDoc: XMLDocument,
|
||||
fb2Blob: Blob,
|
||||
): Promise<Map<string, Blob>> {
|
||||
const resources = new Map<string, Blob>();
|
||||
|
||||
// FB2 can have embedded images (base64) or external references
|
||||
const binary = xmlDoc.querySelector("binary");
|
||||
if (binary) {
|
||||
const contentType = binary.getAttribute("content-type");
|
||||
const id = binary.getAttribute("id");
|
||||
|
||||
if (contentType && id && binary.textContent) {
|
||||
// Decode base64
|
||||
const base64Data = binary.textContent.trim();
|
||||
const byteString = atob(base64Data);
|
||||
const byteArray = new Uint8Array(byteString.length);
|
||||
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
byteArray[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const blob = new Blob([byteArray], { type: contentType });
|
||||
resources.set(`#${id}`, blob);
|
||||
}
|
||||
}
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
function calculateFB2Characters(xmlDoc: XMLDocument): number {
|
||||
const body = xmlDoc.querySelector("body");
|
||||
if (!body) return 0;
|
||||
|
||||
return body.textContent?.length || 0;
|
||||
}
|
||||
|
||||
function generatePageBreaks(totalCharacters: number): number[] {
|
||||
const breaks: number[] = [];
|
||||
const charsPerPage = 1000;
|
||||
|
||||
for (let i = charsPerPage; i < totalCharacters; i += charsPerPage) {
|
||||
breaks.push(i);
|
||||
}
|
||||
|
||||
return breaks;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Metadata Quick Extract
|
||||
// ============================================================
|
||||
|
||||
export async function getFB2Metadata(
|
||||
fb2Blob: Blob,
|
||||
): Promise<Partial<EbookCIF["metadata"]>> {
|
||||
let xmlContent: string;
|
||||
|
||||
if (
|
||||
fb2Blob.type === "application/zip" ||
|
||||
fb2Blob.type === "application/x-zip-compressed"
|
||||
) {
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = await JSZip.loadAsync(fb2Blob);
|
||||
const files = Object.keys(zip.files);
|
||||
|
||||
// Find the first .fb2 file in the zip
|
||||
const fb2File = files.find((f) => f.endsWith(".fb2"));
|
||||
|
||||
if (!fb2File) return {};
|
||||
|
||||
xmlContent = await zip.file(fb2File)!.async("text");
|
||||
} else {
|
||||
xmlContent = await fb2Blob.text();
|
||||
}
|
||||
|
||||
const xmlDoc = parseXML(xmlContent);
|
||||
return extractFB2Metadata(xmlDoc);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
// HTML Parser - Wraps standalone HTML files
|
||||
// Procedural style: Functions, not classes
|
||||
|
||||
// ============================================================
|
||||
// Main Parse Function
|
||||
// ============================================================
|
||||
|
||||
export async function parseHTML(htmlBlob: Blob): Promise<EbookCIF> {
|
||||
const htmlContent = await htmlBlob.text();
|
||||
|
||||
const metadata = extractHTMLMetadata(htmlBlob, htmlContent);
|
||||
const toc = createHTMLTOC(htmlContent);
|
||||
const spine = createHTMLSpine(htmlContent);
|
||||
const resources = await extractHTMLResources(htmlBlob, htmlContent);
|
||||
|
||||
const totalCharacters = stripHTML(htmlContent).length;
|
||||
const pageBreaks = generatePageBreaks(totalCharacters);
|
||||
|
||||
return {
|
||||
metadata,
|
||||
toc,
|
||||
spine,
|
||||
resources,
|
||||
locations: {
|
||||
totalCharacters,
|
||||
pageBreaks,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helper Functions
|
||||
// ============================================================
|
||||
|
||||
function extractHTMLMetadata(
|
||||
htmlBlob: Blob,
|
||||
htmlContent: string,
|
||||
): EbookCIF["metadata"] {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlContent, "text/html");
|
||||
|
||||
const title =
|
||||
doc.querySelector("title")?.textContent ||
|
||||
htmlBlob.name.replace(/\.(html?|htm)$/i, "");
|
||||
|
||||
const metaAuthor = doc
|
||||
.querySelector('meta[name="author"]')
|
||||
?.getAttribute("content");
|
||||
const metaLang = doc.querySelector("html")?.getAttribute("lang") || "en";
|
||||
|
||||
return {
|
||||
title,
|
||||
author: metaAuthor || "Unknown",
|
||||
language: metaLang,
|
||||
};
|
||||
}
|
||||
|
||||
function createHTMLTOC(htmlContent: string): EbookCIF["toc"] {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlContent, "text/html");
|
||||
|
||||
const toc: EbookCIF["toc"] = [];
|
||||
|
||||
// Try to find headings
|
||||
const headings = doc.querySelectorAll("h1, h2, h3");
|
||||
let headingIndex = 0;
|
||||
|
||||
headings.forEach((heading) => {
|
||||
toc.push({
|
||||
id: `heading-${headingIndex}`,
|
||||
title: heading.textContent || "",
|
||||
href: `#${heading.id || `heading-${headingIndex}`}`,
|
||||
children: [],
|
||||
});
|
||||
|
||||
headingIndex++;
|
||||
});
|
||||
|
||||
// If no headings, create single entry
|
||||
if (toc.length === 0) {
|
||||
toc.push({
|
||||
id: "full-document",
|
||||
title: "Full Document",
|
||||
href: "#full-document",
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
return toc;
|
||||
}
|
||||
|
||||
function createHTMLSpine(htmlContent: string): EbookCIF["spine"] {
|
||||
return [
|
||||
{
|
||||
id: "full-document",
|
||||
type: "html",
|
||||
content: htmlContent,
|
||||
index: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function extractHTMLResources(
|
||||
htmlBlob: Blob,
|
||||
htmlContent: string,
|
||||
): Promise<Map<string, Blob>> {
|
||||
const resources = new Map<string, Blob>();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlContent, "text/html");
|
||||
|
||||
// Extract images
|
||||
const images = doc.querySelectorAll("img[src]");
|
||||
|
||||
for (const img of Array.from(images)) {
|
||||
const src = img.getAttribute("src");
|
||||
if (!src) continue;
|
||||
|
||||
// Try to resolve relative URLs
|
||||
if (src.startsWith("data:")) {
|
||||
// Data URI - extract blob
|
||||
const match = src.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (match) {
|
||||
const mimeType = match[1];
|
||||
const base64 = match[2];
|
||||
const byteString = atob(base64);
|
||||
const byteArray = new Uint8Array(byteString.length);
|
||||
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
byteArray[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const blob = new Blob([byteArray], { type: mimeType });
|
||||
resources.set(src, blob);
|
||||
}
|
||||
}
|
||||
// External resources would need to be fetched
|
||||
// For now, skip them (browser will load them naturally)
|
||||
}
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
function stripHTML(html: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = html;
|
||||
return div.textContent || "";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Metadata Quick Extract
|
||||
// ============================================================
|
||||
|
||||
export async function getHTMLMetadata(
|
||||
htmlBlob: Blob,
|
||||
): Promise<Partial<EbookCIF["metadata"]>> {
|
||||
const htmlContent = await htmlBlob.text();
|
||||
return extractHTMLMetadata(htmlBlob, htmlContent);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
// TXT Parser - Wraps plain text in HTML structure
|
||||
// Procedural style: Functions, not classes
|
||||
|
||||
// ============================================================
|
||||
// Main Parse Function
|
||||
// ============================================================
|
||||
|
||||
export async function parseTXT(txtBlob: Blob): Promise<EbookCIF> {
|
||||
const textContent = await txtBlob.text();
|
||||
|
||||
const metadata = extractTXTMetadata(txtBlob);
|
||||
const toc = createTXTTOC(textContent);
|
||||
const spine = createTXTSpine(textContent);
|
||||
const resources = new Map(); // No external resources for plain text
|
||||
|
||||
const totalCharacters = textContent.length;
|
||||
|
||||
return {
|
||||
metadata,
|
||||
toc,
|
||||
spine,
|
||||
resources,
|
||||
locations: {
|
||||
totalCharacters,
|
||||
estimatedPages: Math.ceil(totalCharacters / 1500),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helper Functions
|
||||
// ============================================================
|
||||
|
||||
function extractTXTMetadata(txtBlob: Blob): EbookCIF["metadata"] {
|
||||
const filename = txtBlob.name || "Unknown";
|
||||
|
||||
return {
|
||||
title: filename.replace(/\.(txt|text)$/i, ""),
|
||||
author: "Unknown",
|
||||
language: "en",
|
||||
};
|
||||
}
|
||||
|
||||
function createTXTTOC(textContent: string): EbookCIF["toc"] {
|
||||
// Try to detect chapters (simple heuristic)
|
||||
const toc: EbookCIF["toc"] = [];
|
||||
const lines = textContent.split("\n");
|
||||
|
||||
let chapterIndex = 0;
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
// Common chapter patterns
|
||||
const chapterPattern = /^(chapter|part|section)\s+\d+/i;
|
||||
if (chapterPattern.test(line.trim())) {
|
||||
toc.push({
|
||||
id: `chapter-${chapterIndex}`,
|
||||
title: line.trim(),
|
||||
href: `#chapter-${chapterIndex}`,
|
||||
children: [],
|
||||
});
|
||||
|
||||
chapterIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
// If no chapters found, create single entry
|
||||
if (toc.length === 0) {
|
||||
toc.push({
|
||||
id: "full-text",
|
||||
title: "Full Text",
|
||||
href: "#full-text",
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
|
||||
return toc;
|
||||
}
|
||||
|
||||
function createTXTSpine(textContent: string): EbookCIF["spine"] {
|
||||
// Convert plain text to HTML paragraphs
|
||||
const lines = textContent.split("\n");
|
||||
let htmlContent = '<div class="txt-content">';
|
||||
|
||||
lines.forEach((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
htmlContent += `<p>${escapeHTML(trimmed)}</p>`;
|
||||
} else {
|
||||
htmlContent += "<br />";
|
||||
}
|
||||
});
|
||||
|
||||
htmlContent += "</div>";
|
||||
|
||||
return [
|
||||
{
|
||||
id: "full-text",
|
||||
type: "html",
|
||||
content: htmlContent,
|
||||
index: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function escapeHTML(text: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Removed - backend handles detailed position tracking
|
||||
|
||||
// ============================================================
|
||||
// Metadata Quick Extract
|
||||
// ============================================================
|
||||
|
||||
export async function getTXTMetadata(
|
||||
txtBlob: Blob,
|
||||
): Promise<Partial<EbookCIF["metadata"]>> {
|
||||
return extractTXTMetadata(txtBlob);
|
||||
}
|
||||
@@ -1,565 +0,0 @@
|
||||
import { Alpine } from "../alpine";
|
||||
import { getReaderMetadata, getReadingProgress } from "../api";
|
||||
import "./formats/comic/panel-editor";
|
||||
import { createReaderContext, type ReaderContext } from "./core/reader-context";
|
||||
import { getState, setState } from "./core/reader-state";
|
||||
import { createNavigationAPI } from "./core/reader-navigation";
|
||||
import { readerEvents } from "./core/reader-events";
|
||||
import { renderSpineItem } from "./core/reader-navigation";
|
||||
import { loadSettings } from "./settings-manager";
|
||||
import {
|
||||
calculatePagination,
|
||||
shouldRecalculate,
|
||||
} from "./formats/reflowable/page-calculator";
|
||||
import { restorePosition } from "./formats/reflowable/progress-tracker";
|
||||
import { applyPaginatedStyles } from "./formats/reflowable/content-renderer";
|
||||
import type {
|
||||
PaginationSettings,
|
||||
PaginationData,
|
||||
ReadingPosition,
|
||||
} from "./formats/reflowable/types";
|
||||
import { updatePageDisplay, updateProgressBar } from "./ui/page-display";
|
||||
import { PDFDocumentProxy } from "pdfjs-dist";
|
||||
|
||||
export interface UniversalReader {
|
||||
type: "ebook";
|
||||
cif: EbookCIF;
|
||||
currentSpineIndex: number;
|
||||
currentScrollPosition?: number;
|
||||
currentPage: number;
|
||||
pagination?: PaginationData;
|
||||
position?: ReadingPosition;
|
||||
}
|
||||
|
||||
export interface PDFReader {
|
||||
type: "pdf";
|
||||
doc: PDFDocumentProxy;
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
export interface ComicReader {
|
||||
type: "comic";
|
||||
images: Blob[];
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
export interface MangaReader {
|
||||
type: "manga";
|
||||
images: Blob[];
|
||||
currentPage: number;
|
||||
readingDirection: "rtl" | "vertical";
|
||||
}
|
||||
|
||||
type CurrentReader = UniversalReader | PDFReader | ComicReader | MangaReader;
|
||||
|
||||
let currentReader: CurrentReader | null = null;
|
||||
let readerMetadata: ReaderMetadata | null = null;
|
||||
|
||||
// ============================================================
|
||||
// Feature Registry
|
||||
// ============================================================
|
||||
|
||||
type FeatureInit = (context: ReaderContext) => void | Promise<void>;
|
||||
|
||||
const featureModules: FeatureInit[] = [
|
||||
// Core features
|
||||
(ctx) => import("./ui/gestures").then((m) => m.init(ctx)),
|
||||
(ctx) => import("./ui/keyboard-shortcuts").then((m) => m.init(ctx)),
|
||||
(ctx) => import("./ui/panel-dock-system").then((m) => m.init(ctx)),
|
||||
(ctx) => import("./ui/navigator-panel").then((m) => m.init(ctx)),
|
||||
(ctx) => import("./ui/offline-manager").then((m) => m.init(ctx)),
|
||||
(ctx) => import("./ui/reading-speed-tracker").then((m) => m.init(ctx)),
|
||||
(ctx) => import("./ui/progress-indicator").then((m) => m.init(ctx)),
|
||||
|
||||
// Comic features
|
||||
(ctx) =>
|
||||
import("./formats/comic/background-color").then((m) => m.init?.(ctx)),
|
||||
(ctx) => import("./formats/comic/chapter-markers").then((m) => m.init?.(ctx)),
|
||||
(ctx) => import("./formats/comic/page-cache").then((m) => m.init?.(ctx)),
|
||||
|
||||
// PDF features
|
||||
(ctx) => import("./formats/pdf/pdf-navigation").then((m) => m.init?.(ctx)),
|
||||
(ctx) =>
|
||||
import("./formats/pdf/pdf-text-selection").then((m) => m.init?.(ctx)),
|
||||
(ctx) => import("./formats/pdf/annotation-layer").then((m) => m.init?.(ctx)),
|
||||
|
||||
// Ebook features
|
||||
(ctx) =>
|
||||
import("./formats/reflowable/ebook/copy-handler").then((m) =>
|
||||
m.init?.(ctx),
|
||||
),
|
||||
(ctx) =>
|
||||
import("./formats/reflowable/ebook/font-loader").then((m) => m.init?.(ctx)),
|
||||
|
||||
// Manga features
|
||||
(ctx) =>
|
||||
import("./formats/manga/reading-direction").then((m) => m.init?.(ctx)),
|
||||
(ctx) =>
|
||||
import("./formats/manga/vertical-scroll-mode").then((m) => m.init?.(ctx)),
|
||||
];
|
||||
|
||||
async function initializeFeatures(context: ReaderContext): Promise<void> {
|
||||
const results = await Promise.allSettled(
|
||||
featureModules.map((getInit) => getInit(context)),
|
||||
);
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === "rejected") {
|
||||
console.warn(`Feature ${index} failed to initialize:`, result.reason);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Reader Initialization
|
||||
// ============================================================
|
||||
|
||||
async function initializeReader(): Promise<void> {
|
||||
const mediaItemId = document.body.dataset.mediaItemId;
|
||||
console.log("Initializing reader for:", mediaItemId);
|
||||
if (!mediaItemId) {
|
||||
console.error("No mediaItemId on body");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
readerMetadata = await getReaderMetadata(mediaItemId);
|
||||
console.log("Got metadata:", readerMetadata);
|
||||
console.log("Library type:", readerMetadata.library_type_name);
|
||||
console.log("Mime type:", readerMetadata.mime_type);
|
||||
switch (readerMetadata.format_group) {
|
||||
case "reflowable":
|
||||
if (
|
||||
readerMetadata.mime_type === "application/epub+zip" ||
|
||||
readerMetadata.file_path.endsWith(".epub")
|
||||
) {
|
||||
currentReader = await initializeEbookReader(readerMetadata);
|
||||
}
|
||||
break;
|
||||
case "fixed_layout":
|
||||
currentReader = await initializePDFReader(readerMetadata);
|
||||
break;
|
||||
case "comic_archive":
|
||||
// Check manga_type or library_type_name
|
||||
if (
|
||||
readerMetadata.manga_type === "yes" ||
|
||||
readerMetadata.manga_type === "yes_and_right_to_left" ||
|
||||
readerMetadata.library_type_name === "manga"
|
||||
) {
|
||||
currentReader = await initializeMangaReader(readerMetadata);
|
||||
} else {
|
||||
currentReader = await initializeComicReader(readerMetadata);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!currentReader) return;
|
||||
|
||||
setState({ currentReader, readerMetadata });
|
||||
|
||||
const context = createReaderContext(
|
||||
getState,
|
||||
setState,
|
||||
createNavigationAPI(),
|
||||
() =>
|
||||
(context.render = () => {
|
||||
// Delegates to navigation module
|
||||
}),
|
||||
);
|
||||
|
||||
await initializeFeatures(context);
|
||||
readerEvents.emit("readerReady", currentReader);
|
||||
// Initialize page calculation FIRST, before any rendering
|
||||
const { initializePageCalculation } =
|
||||
await import("./core/reader-navigation");
|
||||
await initializePageCalculation();
|
||||
// Then render the initial chapter content
|
||||
await renderSpineItem();
|
||||
// Focus the content container so keyboard navigation works immediately
|
||||
const container = document.getElementById("reader-content");
|
||||
container?.focus();
|
||||
} catch (error) {
|
||||
console.error("Render initialization failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeEbookReader(
|
||||
metadata: ReaderMetadata,
|
||||
): Promise<UniversalReader> {
|
||||
const { parseEbook, requiresServerParsing } =
|
||||
await import("./core/parser-manager");
|
||||
const needsServer = requiresServerParsing(
|
||||
metadata.mime_type,
|
||||
getFileExtension(metadata.file_path),
|
||||
);
|
||||
let ebookFile: Blob;
|
||||
if (needsServer) {
|
||||
const response = await fetch(`/readers/${metadata.id}/parse`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mime_type: metadata.mime_type,
|
||||
file_path: metadata.file_path,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server parsing failed: ${response.statusText}`);
|
||||
}
|
||||
ebookFile = await response.blob();
|
||||
} else {
|
||||
const response = await fetch(metadata.file_path);
|
||||
ebookFile = await response.blob();
|
||||
}
|
||||
const cif = await parseEbook(
|
||||
ebookFile,
|
||||
metadata.mime_type,
|
||||
getFileExtension(metadata.file_path),
|
||||
);
|
||||
console.log("EPUB Parse Result:", {
|
||||
spineLength: cif.spine?.length,
|
||||
spineItems: cif.spine?.map((s: SpineItem) => s.content),
|
||||
tocLength: cif.toc?.length,
|
||||
keys: Object.keys(cif),
|
||||
});
|
||||
// ============================================================
|
||||
// Fetch saved progress from backend
|
||||
// ============================================================
|
||||
const savedProgress = await getReadingProgress(metadata.id);
|
||||
const savedCFI = savedProgress?.cfi;
|
||||
const savedPage = savedProgress?.current_page;
|
||||
console.log("Saved progress:", { savedCFI, savedPage });
|
||||
// ============================================================
|
||||
// Calculate pagination
|
||||
// ============================================================
|
||||
const userSettings = await loadSettings().catch(() => ({
|
||||
font_size: 16,
|
||||
line_height: 1.6,
|
||||
margin_width: 20,
|
||||
}));
|
||||
const settings: PaginationSettings = {
|
||||
fontSize: userSettings.font_size,
|
||||
lineHeight: userSettings.line_height,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight - 120,
|
||||
wordsPerPage: 250,
|
||||
};
|
||||
console.log("Using settings:", {
|
||||
fontSize: userSettings.font_size,
|
||||
lineHeight: userSettings.line_height,
|
||||
marginWidth: userSettings.margin_width,
|
||||
});
|
||||
const spineItems = cif.spine.map((s: SpineItem) => ({
|
||||
id: s.id,
|
||||
type: "html" as const,
|
||||
content: s.content,
|
||||
index: s.index,
|
||||
}));
|
||||
const pagination = await calculatePagination(
|
||||
spineItems,
|
||||
cif.resources,
|
||||
settings,
|
||||
);
|
||||
console.log("Pagination calculated:", {
|
||||
totalPages: pagination.totalPages,
|
||||
spines: pagination.spines.length,
|
||||
});
|
||||
// ============================================================
|
||||
// Restore saved position or start at page 1
|
||||
// ============================================================
|
||||
const position: ReadingPosition = restorePosition(
|
||||
{
|
||||
type: "ebook",
|
||||
cif: {
|
||||
spine: spineItems,
|
||||
resources: cif.resources,
|
||||
toc: cif.toc || [],
|
||||
metadata: {
|
||||
title: cif.metadata.title || metadata.title,
|
||||
author: cif.metadata.author || metadata.author,
|
||||
language: cif.metadata.language || "en",
|
||||
publisher: cif.metadata.publisher,
|
||||
isbn: cif.metadata.isbn,
|
||||
coverImage: cif.metadata.coverImage,
|
||||
},
|
||||
locations: cif.locations || {
|
||||
totalCharacters: 0,
|
||||
estimatedPages: 0,
|
||||
},
|
||||
},
|
||||
currentSpineIndex: 0,
|
||||
currentPage: 1,
|
||||
pagination,
|
||||
position: {
|
||||
currentPage: 1,
|
||||
spineIndex: 0,
|
||||
localPageIndex: 0,
|
||||
cfi: "",
|
||||
progress: 0,
|
||||
},
|
||||
},
|
||||
savedCFI || "",
|
||||
savedPage,
|
||||
);
|
||||
console.log("Restored position:", position);
|
||||
// ============================================================
|
||||
// Apply paginated styles to container
|
||||
// ============================================================
|
||||
const container = document.getElementById("reader-content");
|
||||
if (container) {
|
||||
applyPaginatedStyles();
|
||||
}
|
||||
// ============================================================
|
||||
// Render the first page
|
||||
// ============================================================
|
||||
const reflowableNav = await import("./formats/reflowable/navigation");
|
||||
const { success, content: pageContent } = reflowableNav.goToPage(
|
||||
{
|
||||
type: "ebook",
|
||||
cif: {
|
||||
spine: spineItems,
|
||||
resources: cif.resources,
|
||||
toc: cif.toc || [],
|
||||
metadata: {
|
||||
title: cif.metadata.title || metadata.title,
|
||||
author: cif.metadata.author || metadata.author,
|
||||
language: cif.metadata.language || "en",
|
||||
publisher: cif.metadata.publisher,
|
||||
isbn: cif.metadata.isbn,
|
||||
coverImage: cif.metadata.coverImage,
|
||||
},
|
||||
locations: cif.locations || {
|
||||
totalCharacters: 0,
|
||||
estimatedPages: 0,
|
||||
},
|
||||
},
|
||||
currentSpineIndex: position.spineIndex,
|
||||
currentPage: position.currentPage,
|
||||
pagination,
|
||||
position,
|
||||
},
|
||||
position.currentPage,
|
||||
);
|
||||
if (success && container) {
|
||||
const { renderPage } =
|
||||
await import("./formats/reflowable/content-renderer");
|
||||
renderPage(container, pageContent);
|
||||
// Update UI
|
||||
updatePageDisplay(container, position.currentPage, pagination.totalPages);
|
||||
updateProgressBar(container, Math.round(position.progress * 100));
|
||||
}
|
||||
return {
|
||||
type: "ebook",
|
||||
cif,
|
||||
currentSpineIndex: position.spineIndex,
|
||||
currentPage: position.currentPage,
|
||||
pagination,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
async function initializePDFReader(
|
||||
metadata: ReaderMetadata,
|
||||
): Promise<PDFReader> {
|
||||
const { initializePDFReader } = await import("./formats/pdf/pdfjs-wrapper");
|
||||
return initializePDFReader(metadata);
|
||||
}
|
||||
|
||||
async function initializeComicReader(
|
||||
metadata: ReaderMetadata,
|
||||
): Promise<ComicReader> {
|
||||
const { initializeComicReader } =
|
||||
await import("./formats/comic/image-parser");
|
||||
return initializeComicReader(metadata);
|
||||
}
|
||||
|
||||
async function initializeMangaReader(
|
||||
metadata: ReaderMetadata,
|
||||
): Promise<MangaReader> {
|
||||
const { initializeMangaReader } =
|
||||
await import("./formats/comic/image-parser");
|
||||
return initializeMangaReader(metadata);
|
||||
}
|
||||
|
||||
function getFileExtension(filepath: string): string {
|
||||
const match = filepath.match(/\.([^.]+)$/);
|
||||
return match ? `.${match[1]}` : "";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Viewport Resize Handler
|
||||
// ============================================================
|
||||
function handleResize(): void {
|
||||
const state = getState();
|
||||
if (!state.currentReader || state.currentReader.type !== "ebook") return;
|
||||
const book = state.currentReader as UniversalReader;
|
||||
if (!book.pagination) return;
|
||||
loadSettings()
|
||||
.then((userSettings) => {
|
||||
const newSettings: PaginationSettings = {
|
||||
fontSize: userSettings.font_size,
|
||||
lineHeight: userSettings.line_height,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight - 120,
|
||||
wordsPerPage: 250,
|
||||
};
|
||||
if (shouldRecalculate(book.pagination, newSettings)) {
|
||||
const currentCFI = book.position.cfi;
|
||||
const currentPage = book.position.currentPage;
|
||||
calculatePagination(book.cif.spine, book.cif.resources, newSettings)
|
||||
.then((newPagination) => {
|
||||
const updatedBook: UniversalReader = {
|
||||
type: "ebook",
|
||||
cif: book.cif,
|
||||
currentSpineIndex: book.position.spineIndex,
|
||||
currentPage: book.position.currentPage,
|
||||
pagination: newPagination,
|
||||
position: {
|
||||
currentPage: currentPage,
|
||||
spineIndex: book.position.spineIndex,
|
||||
localPageIndex: book.position.localPageIndex,
|
||||
cfi: currentCFI,
|
||||
progress: currentPage / newPagination.totalPages,
|
||||
},
|
||||
};
|
||||
setState({ currentReader: updatedBook });
|
||||
const container = document.getElementById("reader-content");
|
||||
if (container) {
|
||||
const reflowableNav = require("./formats/reflowable/navigation");
|
||||
const { success, content: pageContent } = reflowableNav.goToPage(
|
||||
updatedBook,
|
||||
updatedBook.position.currentPage,
|
||||
);
|
||||
if (success) {
|
||||
const {
|
||||
renderPage,
|
||||
} = require("./formats/reflowable/content-renderer");
|
||||
renderPage(container, pageContent);
|
||||
updatePageDisplay(
|
||||
container,
|
||||
updatedBook.position.currentPage,
|
||||
newPagination.totalPages,
|
||||
);
|
||||
updateProgressBar(
|
||||
container,
|
||||
Math.round(updatedBook.position.progress * 100),
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Pagination recalculation failed:", error);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
"Failed to load user settings for resize, using defaults:",
|
||||
error,
|
||||
);
|
||||
const newSettings: PaginationSettings = {
|
||||
fontSize: 16,
|
||||
lineHeight: 1.6,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight - 120,
|
||||
wordsPerPage: 250,
|
||||
};
|
||||
if (shouldRecalculate(book.pagination, newSettings)) {
|
||||
const currentCFI = book.position.cfi;
|
||||
const currentPage = book.position.currentPage;
|
||||
calculatePagination(book.cif.spine, book.cif.resources, newSettings)
|
||||
.then((newPagination) => {
|
||||
const updatedBook: UniversalReader = {
|
||||
type: "ebook",
|
||||
cif: book.cif,
|
||||
currentSpineIndex: book.position.spineIndex,
|
||||
currentPage: book.position.currentPage,
|
||||
pagination: newPagination,
|
||||
position: {
|
||||
currentPage: currentPage,
|
||||
spineIndex: book.position.spineIndex,
|
||||
localPageIndex: book.position.localPageIndex,
|
||||
cfi: currentCFI,
|
||||
progress: currentPage / newPagination.totalPages,
|
||||
},
|
||||
};
|
||||
setState({ currentReader: updatedBook });
|
||||
const container = document.getElementById("reader-content");
|
||||
if (container) {
|
||||
const reflowableNav = require("./formats/reflowable/navigation");
|
||||
const { success, content: pageContent } = reflowableNav.goToPage(
|
||||
updatedBook,
|
||||
updatedBook.position.currentPage,
|
||||
);
|
||||
if (success) {
|
||||
const {
|
||||
renderPage,
|
||||
} = require("./formats/reflowable/content-renderer");
|
||||
renderPage(container, pageContent);
|
||||
updatePageDisplay(
|
||||
container,
|
||||
updatedBook.position.currentPage,
|
||||
newPagination.totalPages,
|
||||
);
|
||||
updateProgressBar(
|
||||
container,
|
||||
Math.round(updatedBook.position.progress * 100),
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Pagination recalculation failed:", error);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
function debounce(
|
||||
func: (...args: any[]) => void,
|
||||
wait: number,
|
||||
): (...args: any[]) => void {
|
||||
let timeout: any;
|
||||
return function (this: any, ...args: any[]) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
window.addEventListener("resize", debounce(handleResize, 300));
|
||||
// ============================================================
|
||||
// Alpine.js Integration
|
||||
// ============================================================
|
||||
|
||||
Alpine.data("readerShell", () => ({
|
||||
initReader() {
|
||||
initializeReader();
|
||||
},
|
||||
|
||||
get currentPage() {
|
||||
const state = getState();
|
||||
if (!state.currentReader) return 0;
|
||||
|
||||
if (state.currentReader.type === "ebook") {
|
||||
// Use stored computed page if available
|
||||
if (state.currentReader.currentPage) {
|
||||
return state.currentReader.currentPage;
|
||||
}
|
||||
return state.currentReader.currentSpineIndex + 1;
|
||||
}
|
||||
return state.currentReader.currentPage;
|
||||
},
|
||||
|
||||
get totalPages() {
|
||||
const state = getState();
|
||||
if (!state.currentReader || !state.readerMetadata) return 0;
|
||||
if (state.currentReader.type === "ebook") {
|
||||
// Use pagination if available
|
||||
if ((state.currentReader as UniversalReader).pagination) {
|
||||
return (state.currentReader as UniversalReader).pagination!.totalPages;
|
||||
}
|
||||
return state.currentReader.cif.spine.length;
|
||||
} else if (state.currentReader.type === "pdf") {
|
||||
return state.readerMetadata.total_pages || 0;
|
||||
} else {
|
||||
return state.currentReader.images.length;
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -1,276 +0,0 @@
|
||||
import type { Panel } from "../formats/comic/panel-detector";
|
||||
import { detectPanels } from "../formats/comic/panel-detection.service";
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
let currentPanelIndex = 0;
|
||||
let currentPagePanels: Panel[] = [];
|
||||
let context: ReaderContext;
|
||||
|
||||
export async function init(readerContext: ReaderContext): Promise<void> {
|
||||
context = readerContext;
|
||||
setupGestures();
|
||||
await loadPanelsIfComic();
|
||||
}
|
||||
|
||||
function setupGestures() {
|
||||
const container = context.elements.readerContent;
|
||||
if (!container) return;
|
||||
|
||||
const state = {
|
||||
touchStartX: 0,
|
||||
touchStartY: 0,
|
||||
touchStartTime: 0,
|
||||
lastTapTime: 0,
|
||||
initialPinchDistance: 0,
|
||||
scale: 1,
|
||||
};
|
||||
|
||||
container.addEventListener(
|
||||
"touchstart",
|
||||
(e) => {
|
||||
if (e.touches.length === 1) {
|
||||
state.touchStartX = e.touches[0].clientX;
|
||||
state.touchStartY = e.touches[0].clientY;
|
||||
state.touchStartTime = Date.now();
|
||||
} else if (e.touches.length === 2) {
|
||||
state.initialPinchDistance = getPinchDistance(e.touches);
|
||||
}
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
|
||||
container.addEventListener(
|
||||
"touchend",
|
||||
(e) => {
|
||||
const deltaX = e.changedTouches[0].clientX - state.touchStartX;
|
||||
const deltaY = e.changedTouches[0].clientY - state.touchStartY;
|
||||
const deltaTime = Date.now() - state.touchStartTime;
|
||||
|
||||
if (Math.abs(deltaX) < 30 && Math.abs(deltaY) < 30 && deltaTime < 300) {
|
||||
const now = Date.now();
|
||||
if (now - state.lastTapTime < 300) {
|
||||
handleDoubleTap();
|
||||
state.lastTapTime = 0;
|
||||
} else {
|
||||
state.lastTapTime = now;
|
||||
setTimeout(() => {
|
||||
if (state.lastTapTime !== 0) {
|
||||
handleTap();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const minSwipeDistance = 50;
|
||||
const maxSwipeTime = 500;
|
||||
|
||||
if (deltaTime > maxSwipeTime) return;
|
||||
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
if (deltaX > minSwipeDistance) {
|
||||
handleSwipeRight();
|
||||
} else if (deltaX < -minSwipeDistance) {
|
||||
handleSwipeLeft();
|
||||
}
|
||||
} else {
|
||||
if (deltaY > minSwipeDistance) {
|
||||
handleSwipeDown();
|
||||
} else if (deltaY < -minSwipeDistance) {
|
||||
handleSwipeUp();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
|
||||
container.addEventListener(
|
||||
"touchmove",
|
||||
(e) => {
|
||||
if (e.touches.length === 2) {
|
||||
const currentDistance = getPinchDistance(e.touches);
|
||||
if (state.initialPinchDistance > 0) {
|
||||
const scale = currentDistance / state.initialPinchDistance;
|
||||
handlePinch(scale);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handleSwipeLeft() {
|
||||
const metadata = context.getState().readerMetadata;
|
||||
if (metadata?.library_type_name === "manga") {
|
||||
navigateWithPanels("next");
|
||||
} else {
|
||||
context.navigation.previousPage();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSwipeRight() {
|
||||
const metadata = context.getState().readerMetadata;
|
||||
if (metadata?.library_type_name === "manga") {
|
||||
navigateWithPanels("previous");
|
||||
} else {
|
||||
context.navigation.nextPage();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSwipeUp() {
|
||||
const chrome = context.elements.chrome;
|
||||
if (chrome) chrome.classList.remove("visible");
|
||||
}
|
||||
|
||||
function handleSwipeDown() {
|
||||
const chrome = context.elements.chrome;
|
||||
if (chrome) chrome.classList.add("visible");
|
||||
}
|
||||
|
||||
function handleTap() {
|
||||
const chrome = context.elements.chrome;
|
||||
if (chrome) chrome.classList.toggle("visible");
|
||||
}
|
||||
|
||||
function handleDoubleTap() {
|
||||
const container = context.elements.readerContent;
|
||||
if (container) {
|
||||
const currentTransform = container.style.transform || "";
|
||||
const currentScale = currentTransform.match(/scale\(([\d.]+)\)/);
|
||||
const scale = currentScale ? parseFloat(currentScale[1]) : 1;
|
||||
const newScale = scale === 1 ? 1.5 : 1;
|
||||
container.style.transform = `scale(${newScale})`;
|
||||
container.style.transformOrigin = "center center";
|
||||
context.events.emit("zoomChanged", newScale);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePinch(scale: number) {
|
||||
const container = context.elements.readerContent;
|
||||
if (container && scale >= 0.5 && scale <= 3) {
|
||||
container.style.transform = `scale(${scale})`;
|
||||
container.style.transformOrigin = "center center";
|
||||
context.events.emit("zoomChanged", scale);
|
||||
}
|
||||
}
|
||||
|
||||
function getPinchDistance(touches: TouchList): number {
|
||||
const dx = touches[0].clientX - touches[1].clientX;
|
||||
const dy = touches[0].clientY - touches[1].clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
async function loadPanelsIfComic() {
|
||||
const state = context.getState();
|
||||
if (
|
||||
!state.currentReader ||
|
||||
(state.currentReader.type !== "comic" &&
|
||||
state.currentReader.type !== "manga")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = state.currentReader;
|
||||
if (
|
||||
!reader.images ||
|
||||
reader.currentPage === undefined ||
|
||||
reader.currentPage < 0 ||
|
||||
reader.currentPage >= reader.images.length
|
||||
) {
|
||||
currentPagePanels = [];
|
||||
currentPanelIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const imageBlob = reader.images[reader.currentPage];
|
||||
const imageData = await blobToImageData(imageBlob);
|
||||
const result = await detectPanels(imageData, false);
|
||||
currentPagePanels = result.panels;
|
||||
currentPanelIndex = 0;
|
||||
} catch (error) {
|
||||
console.warn("Failed to load panels:", error);
|
||||
currentPagePanels = [];
|
||||
currentPanelIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function blobToImageData(blob: Blob): Promise<ImageData> {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject(new Error("Failed to get canvas context"));
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(imageData);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error("Failed to load image"));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function navigateWithPanels(direction: "next" | "previous") {
|
||||
if (currentPagePanels.length === 0) {
|
||||
if (direction === "next") {
|
||||
context.navigation.nextPage();
|
||||
} else {
|
||||
context.navigation.previousPage();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
direction === "next" &&
|
||||
currentPanelIndex < currentPagePanels.length - 1
|
||||
) {
|
||||
currentPanelIndex++;
|
||||
scrollToPanel(currentPanelIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (direction === "previous" && currentPanelIndex > 0) {
|
||||
currentPanelIndex--;
|
||||
scrollToPanel(currentPanelIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// No more panels, go to next/previous page
|
||||
if (direction === "next") {
|
||||
context.navigation.nextPage();
|
||||
} else {
|
||||
context.navigation.previousPage();
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToPanel(panelIndex: number) {
|
||||
const panel = currentPagePanels[panelIndex];
|
||||
if (!panel) return;
|
||||
|
||||
const container = context.elements.readerContent;
|
||||
if (!container) return;
|
||||
|
||||
const panelElement =
|
||||
container.querySelector(`[data-panel-id="${panel.id}"]`) ||
|
||||
container.querySelector(`#${panel.id}`);
|
||||
|
||||
if (panelElement) {
|
||||
panelElement.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
document
|
||||
.querySelectorAll(".panel-current")
|
||||
.forEach((el) => el.classList.remove("panel-current"));
|
||||
panelElement.classList.add("panel-current");
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
let context: ReaderContext;
|
||||
|
||||
export async function init(readerContext: ReaderContext): Promise<void> {
|
||||
context = readerContext;
|
||||
setupKeyboardShortcuts();
|
||||
}
|
||||
|
||||
function setupKeyboardShortcuts() {
|
||||
const container = context.elements.readerContent;
|
||||
if (!container) return;
|
||||
|
||||
const state = context.getState();
|
||||
let maxPage = 0;
|
||||
|
||||
if (state.currentReader?.type === "ebook") {
|
||||
maxPage = state.currentReader.cif.spine.length;
|
||||
} else if (state.currentReader?.type === "pdf") {
|
||||
maxPage = state.readerMetadata?.total_pages || 0;
|
||||
} else if (
|
||||
state.currentReader?.type === "comic" ||
|
||||
state.currentReader?.type === "manga"
|
||||
) {
|
||||
const reader = state.currentReader;
|
||||
maxPage = reader.images.length;
|
||||
}
|
||||
|
||||
container.addEventListener("keydown", (e) => {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowRight":
|
||||
case "PageDown":
|
||||
case "l":
|
||||
e.preventDefault();
|
||||
context.navigation.nextPage();
|
||||
break;
|
||||
|
||||
case "ArrowLeft":
|
||||
case "PageUp":
|
||||
case "h":
|
||||
e.preventDefault();
|
||||
context.navigation.previousPage();
|
||||
break;
|
||||
|
||||
case "ArrowUp":
|
||||
case "k":
|
||||
e.preventDefault();
|
||||
context.navigation.previousPage();
|
||||
break;
|
||||
|
||||
case "ArrowDown":
|
||||
case "j":
|
||||
e.preventDefault();
|
||||
context.navigation.nextPage();
|
||||
break;
|
||||
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
context.navigation.nextPage();
|
||||
break;
|
||||
|
||||
case "Home":
|
||||
e.preventDefault();
|
||||
context.navigation.goToPage(1);
|
||||
break;
|
||||
|
||||
case "End":
|
||||
e.preventDefault();
|
||||
context.navigation.goToPage(maxPage);
|
||||
break;
|
||||
|
||||
case "b":
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
toggleBookmark();
|
||||
}
|
||||
break;
|
||||
|
||||
case "+":
|
||||
case "=":
|
||||
e.preventDefault();
|
||||
zoomIn();
|
||||
break;
|
||||
|
||||
case "-":
|
||||
case "_":
|
||||
e.preventDefault();
|
||||
zoomOut();
|
||||
break;
|
||||
|
||||
case "0":
|
||||
e.preventDefault();
|
||||
zoomReset();
|
||||
break;
|
||||
|
||||
case "?":
|
||||
e.preventDefault();
|
||||
showShortcutHelp();
|
||||
break;
|
||||
|
||||
case "f":
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
}
|
||||
break;
|
||||
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
exitFullscreen();
|
||||
break;
|
||||
|
||||
default:
|
||||
if (e.key >= "1" && e.key <= "9") {
|
||||
const targetPage = Math.floor((parseInt(e.key) / 10) * maxPage);
|
||||
e.preventDefault();
|
||||
context.navigation.goToPage(targetPage);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleBookmark() {
|
||||
// TODO: Implement bookmark toggle
|
||||
console.log("Toggle bookmark");
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
const container = context.elements.readerContent;
|
||||
if (container) {
|
||||
const currentTransform = container.style.transform || "";
|
||||
const currentScale = currentTransform.match(/scale\(([\d.]+)\)/);
|
||||
const scale = currentScale ? parseFloat(currentScale[1]) : 1;
|
||||
const newScale = Math.min(scale + 0.25, 3);
|
||||
container.style.transform = `scale(${newScale})`;
|
||||
container.style.transformOrigin = "center center";
|
||||
context.events.emit("zoomChanged", newScale);
|
||||
}
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
const container = context.elements.readerContent;
|
||||
if (container) {
|
||||
const currentTransform = container.style.transform || "";
|
||||
const currentScale = currentTransform.match(/scale\(([\d.]+)\)/);
|
||||
const scale = currentScale ? parseFloat(currentScale[1]) : 1;
|
||||
const newScale = Math.max(scale - 0.25, 0.5);
|
||||
container.style.transform = `scale(${newScale})`;
|
||||
container.style.transformOrigin = "center center";
|
||||
context.events.emit("zoomChanged", newScale);
|
||||
}
|
||||
}
|
||||
|
||||
function zoomReset() {
|
||||
const container = context.elements.readerContent;
|
||||
if (container) {
|
||||
container.style.transform = "scale(1)";
|
||||
container.style.transformOrigin = "center center";
|
||||
context.events.emit("zoomChanged", 1);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
document.documentElement.requestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
function exitFullscreen() {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
function showShortcutHelp() {
|
||||
const help = document.createElement("div");
|
||||
help.className =
|
||||
"keyboard-shortcut-help fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50";
|
||||
help.innerHTML = `
|
||||
<div class="bg-gray-800 rounded-lg p-6 max-w-md">
|
||||
<h2 class="text-xl font-bold mb-4">Keyboard Shortcuts</h2>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div><kbd class="bg-gray-700 px-2 py-1 rounded">→</kbd> / <kbd class="bg-gray-700 px-2 py-1 rounded">Space</kbd> Next page</div>
|
||||
<div><kbd class="bg-gray-700 px-2 py-1 rounded">←</kbd> Previous page</div>
|
||||
<div><kbd class="bg-gray-700 px-2 py-1 rounded">Home</kbd> First page</div>
|
||||
<div><kbd class="bg-gray-700 px-2 py-1 rounded">End</kbd> Last page</div>
|
||||
<div><kbd class="bg-gray-700 px-2 py-1 rounded">+</kbd> / <kbd class="bg-gray-700 px-2 py-1 rounded">-</kbd> Zoom</div>
|
||||
<div><kbd class="bg-gray-700 px-2 py-1 rounded">B</kbd> Toggle bookmark</div>
|
||||
<div><kbd class="bg-gray-700 px-2 py-1 rounded">?</kbd> Show help</div>
|
||||
<div><kbd class="bg-gray-700 px-2 py-1 rounded">Esc</kbd> Exit fullscreen</div>
|
||||
</div>
|
||||
<button class="mt-4 px-4 py-2 bg-blue-600 rounded" onclick="this.closest('.keyboard-shortcut-help').remove()">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(help);
|
||||
help.addEventListener("click", (e) => {
|
||||
if (e.target === help) help.remove();
|
||||
});
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
// Navigator panel - shows full page with draggable viewport box
|
||||
// Affinity/Photoshop-style mini-map for page navigation
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
let context: ReaderContext;
|
||||
|
||||
export function init(readerContext: ReaderContext): void {
|
||||
context = readerContext;
|
||||
let state: NavigatorState | null = null;
|
||||
|
||||
context.events.on(
|
||||
"navigator:initialize",
|
||||
(detail: { containerSelector: string; totalPages: number }) => {
|
||||
state = initializeNavigator(detail.containerSelector);
|
||||
state.totalPages = detail.totalPages;
|
||||
},
|
||||
);
|
||||
|
||||
context.events.on(
|
||||
"navigator:update",
|
||||
(detail: { currentPage: number; contentImage?: HTMLImageElement }) => {
|
||||
if (state) {
|
||||
state.currentPage = detail.currentPage;
|
||||
if (detail.contentImage) {
|
||||
state.contentImage = detail.contentImage;
|
||||
updateNavigatorViewport(state);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
context.events.on("navigator:pan", (detail: { x: number; y: number }) => {
|
||||
if (state) {
|
||||
handleNavigatorPan(state, detail.x, detail.y);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface NavigatorState {
|
||||
panelId: string;
|
||||
container: HTMLElement;
|
||||
viewport: HTMLElement;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
scale: number;
|
||||
contentImage: HTMLImageElement | null;
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
function initializeNavigator(containerSelector: string): NavigatorState {
|
||||
const container = document.querySelector(containerSelector) as HTMLElement;
|
||||
if (!container) throw new Error("Navigator container not found");
|
||||
|
||||
const viewport = document.createElement("div");
|
||||
viewport.className = "navigator-viewport-box";
|
||||
viewport.style.cssText = `
|
||||
position: absolute;
|
||||
border: 2px solid var(--accent-color, #3b82f6);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
cursor: move;
|
||||
z-index: 10;
|
||||
`;
|
||||
|
||||
container.appendChild(viewport);
|
||||
|
||||
const state: NavigatorState = {
|
||||
panelId: "navigator",
|
||||
container,
|
||||
viewport,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
scale: 0.1,
|
||||
contentImage: null,
|
||||
isDragging: false,
|
||||
};
|
||||
|
||||
setupNavigatorDragHandler(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function setupNavigatorDragHandler(state: NavigatorState): void {
|
||||
state.viewport.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
state.isDragging = true;
|
||||
state.viewport.style.cursor = "grabbing";
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", (e) => {
|
||||
if (!state.isDragging || !state.contentImage) return;
|
||||
|
||||
const imgRect = state.contentImage.getBoundingClientRect();
|
||||
|
||||
const relX = (e.clientX - imgRect.left) / imgRect.width;
|
||||
const relY = (e.clientY - imgRect.top) / imgRect.height;
|
||||
|
||||
const mainViewer = document.getElementById("reader-content");
|
||||
if (mainViewer) {
|
||||
mainViewer.dispatchEvent(
|
||||
new CustomEvent("navigator-pan", {
|
||||
detail: { x: relX, y: relY },
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mouseup", () => {
|
||||
if (state.isDragging) {
|
||||
state.isDragging = false;
|
||||
state.viewport.style.cursor = "move";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateNavigatorViewport(state: NavigatorState): void {
|
||||
if (!state.contentImage) return;
|
||||
|
||||
const containerRect = state.container.getBoundingClientRect();
|
||||
const imgRect = state.contentImage.getBoundingClientRect();
|
||||
|
||||
state.scale = containerRect.width / imgRect.width;
|
||||
|
||||
state.viewport.style.width = `${containerRect.width}px`;
|
||||
state.viewport.style.height = `${containerRect.height * state.scale}px`;
|
||||
|
||||
state.viewport.style.left = "0";
|
||||
state.viewport.style.top = `${(state.currentPage - 1) * imgRect.height * state.scale}px`;
|
||||
}
|
||||
|
||||
function handleNavigatorPan(state: NavigatorState, x: number, y: number): void {
|
||||
if (!state.contentImage) return;
|
||||
|
||||
const viewportX = x * state.container.offsetWidth;
|
||||
const viewportY = y * state.container.offsetHeight;
|
||||
|
||||
state.viewport.style.left = `${viewportX}px`;
|
||||
state.viewport.style.top = `${viewportY}px`;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// Offline manager for PWA functionality
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
let context: ReaderContext;
|
||||
|
||||
export function init(readerContext: ReaderContext): void {
|
||||
context = readerContext;
|
||||
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) {
|
||||
navigator.serviceWorker
|
||||
.register("/static/service-worker.js")
|
||||
.then((registration) => {
|
||||
console.log("Service worker registered:", registration);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Service worker registration failed:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function checkOnlineStatus(): boolean {
|
||||
if (typeof navigator !== "undefined" && navigator.onLine) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function syncPendingChanges(context: ReaderContext): void {
|
||||
context.events.emit("offline:sync", {});
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Display page info (Page X of Y)
|
||||
export function updatePageDisplay(
|
||||
container: HTMLElement,
|
||||
currentPage: number,
|
||||
totalPages: number,
|
||||
): void {
|
||||
const existing = container.querySelector(".page-display");
|
||||
existing?.remove();
|
||||
|
||||
const display = document.createElement("div");
|
||||
display.className = "page-display";
|
||||
display.textContent = `Page ${currentPage} of ${totalPages}`;
|
||||
display.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
z-index: 100;
|
||||
`;
|
||||
|
||||
container.appendChild(display);
|
||||
}
|
||||
|
||||
// Remove page display
|
||||
export function removePageDisplay(container: HTMLElement): void {
|
||||
const existing = container.querySelector(".page-display");
|
||||
existing?.remove();
|
||||
}
|
||||
|
||||
// Update progress bar
|
||||
export function updateProgressBar(
|
||||
container: HTMLElement,
|
||||
percentage: number,
|
||||
): void {
|
||||
let bar = container.querySelector(".progress-bar-fill") as HTMLElement;
|
||||
|
||||
if (!bar) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "progress-bar";
|
||||
wrapper.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--bg-secondary);
|
||||
z-index: 100;
|
||||
`;
|
||||
|
||||
bar = document.createElement("div");
|
||||
bar.className = "progress-bar-fill";
|
||||
bar.style.cssText = `
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 0.3s ease;
|
||||
`;
|
||||
|
||||
wrapper.appendChild(bar);
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
bar.style.width = `${percentage}%`;
|
||||
}
|
||||
|
||||
// Remove progress bar
|
||||
export function removeProgressBar(container: HTMLElement): void {
|
||||
const existing = container.querySelector(".progress-bar");
|
||||
existing?.remove();
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
// Modular dockable panel system - handles drag, lock, snap-back, window-shade
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
let context: ReaderContext;
|
||||
|
||||
export function init(readerContext: ReaderContext): void {
|
||||
context = readerContext;
|
||||
const dockZones: DockZone[] = [
|
||||
{ side: "left", x: 0, width: 400, height: window.innerHeight },
|
||||
{
|
||||
side: "right",
|
||||
x: window.innerWidth - 400,
|
||||
width: 400,
|
||||
height: window.innerHeight,
|
||||
},
|
||||
];
|
||||
|
||||
const state: PanelDockState = {
|
||||
panels: new Map(),
|
||||
dragState: null,
|
||||
dockZones,
|
||||
};
|
||||
|
||||
context.events.on("panels:initialize", async () => {
|
||||
const settings = await loadSettings();
|
||||
for (const [panelId, panelState] of Object.entries(settings.panel_layout)) {
|
||||
state.panels.set(panelId, panelState as PanelState);
|
||||
createPanel(context, panelId, panelState as PanelState);
|
||||
}
|
||||
setupGlobalDragHandlers(context, state);
|
||||
});
|
||||
|
||||
context.events.on("panel:toggle", (detail: { panelId: string }) => {
|
||||
const panelState = state.panels.get(detail.panelId);
|
||||
if (panelState) {
|
||||
panelState.visible = !panelState.visible;
|
||||
updatePanelVisibility(detail.panelId, panelState.visible);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on("panel:collapse", (detail: { panelId: string }) => {
|
||||
const panelState = state.panels.get(detail.panelId);
|
||||
if (panelState) {
|
||||
panelState.collapsed = !panelState.collapsed;
|
||||
updatePanelCollapsed(detail.panelId, panelState.collapsed);
|
||||
}
|
||||
});
|
||||
|
||||
context.events.on(
|
||||
"panel:move",
|
||||
(detail: { panelId: string; side: "left" | "right" }) => {
|
||||
movePanelToSide(context, state, detail.panelId, detail.side);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
interface PanelDockState {
|
||||
panels: Map<string, PanelState>;
|
||||
dragState: DragState | null;
|
||||
dockZones: DockZone[];
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
panelId: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
currentX: number;
|
||||
currentY: number;
|
||||
isLocked: boolean;
|
||||
}
|
||||
|
||||
interface DockZone {
|
||||
side: "left" | "right";
|
||||
x: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface PanelState {
|
||||
side: "left" | "right";
|
||||
visible: boolean;
|
||||
collapsed: boolean;
|
||||
width_px: number;
|
||||
order: number;
|
||||
locked: boolean;
|
||||
last_valid_side: "left" | "right";
|
||||
}
|
||||
|
||||
async function loadSettings(): Promise<any> {
|
||||
const response = await fetch("/readers/settings");
|
||||
if (!response.ok) return {};
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
function createPanel(
|
||||
context: ReaderContext,
|
||||
panelId: string,
|
||||
panelState: PanelState,
|
||||
): void {
|
||||
const panel = document.createElement("div");
|
||||
panel.id = `panel-${panelId}`;
|
||||
panel.className = `reader-panel panel-${panelState.side}`;
|
||||
panel.dataset.panelId = panelId;
|
||||
panel.style.width = `${panelState.width_px}px`;
|
||||
|
||||
if (!panelState.visible) {
|
||||
panel.classList.add("panel-hidden");
|
||||
}
|
||||
|
||||
if (panelState.collapsed) {
|
||||
panel.classList.add("panel-collapsed");
|
||||
}
|
||||
|
||||
context.elements.readerContent.appendChild(panel);
|
||||
}
|
||||
|
||||
function updatePanelVisibility(panelId: string, visible: boolean): void {
|
||||
const panel = document.getElementById(`panel-${panelId}`);
|
||||
if (panel) {
|
||||
panel.classList.toggle("panel-hidden", !visible);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePanelCollapsed(panelId: string, collapsed: boolean): void {
|
||||
const panel = document.getElementById(`panel-${panelId}`);
|
||||
if (panel) {
|
||||
panel.classList.toggle("panel-collapsed", collapsed);
|
||||
}
|
||||
}
|
||||
|
||||
function setupGlobalDragHandlers(
|
||||
context: ReaderContext,
|
||||
state: PanelDockState,
|
||||
): void {
|
||||
document.addEventListener("mousedown", (e) => {
|
||||
const panelHeader = (e.target as HTMLElement).closest(".panel-header");
|
||||
if (panelHeader) {
|
||||
const panelId = (panelHeader as HTMLElement).dataset.panelId;
|
||||
if (panelId) {
|
||||
const panelState = state.panels.get(panelId);
|
||||
if (panelState && !panelState.locked) {
|
||||
state.dragState = {
|
||||
panelId,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
currentX: e.clientX,
|
||||
currentY: e.clientY,
|
||||
isLocked: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", (e) => {
|
||||
if (!state.dragState) return;
|
||||
state.dragState.currentX = e.clientX;
|
||||
state.dragState.currentY = e.clientY;
|
||||
});
|
||||
|
||||
document.addEventListener("mouseup", () => {
|
||||
if (state.dragState) {
|
||||
const nearestZone = findNearestDockZone(
|
||||
state.dragState.currentX,
|
||||
state.dockZones,
|
||||
);
|
||||
if (nearestZone) {
|
||||
movePanelToSide(
|
||||
context,
|
||||
state,
|
||||
state.dragState.panelId,
|
||||
nearestZone.side,
|
||||
);
|
||||
}
|
||||
state.dragState = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function findNearestDockZone(x: number, zones: DockZone[]): DockZone | null {
|
||||
let nearest: DockZone | null = null;
|
||||
let minDistance = Infinity;
|
||||
|
||||
for (const zone of zones) {
|
||||
const distance = Math.abs(x - (zone.x + zone.width / 2));
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
nearest = zone;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
function movePanelToSide(
|
||||
context: ReaderContext,
|
||||
state: PanelDockState,
|
||||
panelId: string,
|
||||
side: "left" | "right",
|
||||
): void {
|
||||
const panelState = state.panels.get(panelId);
|
||||
if (!panelState) return;
|
||||
|
||||
panelState.side = side;
|
||||
panelState.last_valid_side = side;
|
||||
|
||||
const panel = document.getElementById(`panel-${panelId}`);
|
||||
if (panel) {
|
||||
panel.classList.remove("panel-left", "panel-right");
|
||||
panel.classList.add(`panel-${side}`);
|
||||
}
|
||||
|
||||
context.events.emit("panel:moved", { panelId, side });
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
interface ProgressDisplay {
|
||||
mode: "pages" | "chapter" | "percentage" | "time-left";
|
||||
text: string;
|
||||
}
|
||||
|
||||
let context: ReaderContext;
|
||||
|
||||
export async function init(readerContext: ReaderContext): Promise<void> {
|
||||
context = readerContext;
|
||||
context.events.on("pageChanged", () => {
|
||||
updateProgressDisplay(context);
|
||||
});
|
||||
context.events.on("progressUpdated", () => {
|
||||
updateProgressDisplay(context);
|
||||
});
|
||||
context.events.on("readerReady", () => {
|
||||
updateProgressDisplay(context);
|
||||
});
|
||||
setupProgressModeCycling(context);
|
||||
}
|
||||
export { calculateProgress, cycleProgressMode };
|
||||
export type { ProgressDisplay };
|
||||
function calculateProgress(
|
||||
currentPage: number,
|
||||
totalPages: number,
|
||||
currentChapterPage: number,
|
||||
chapterPages: number,
|
||||
readingSpeed?: ReadingSpeed,
|
||||
): ProgressDisplay {
|
||||
const mode = getCurrentProgressMode();
|
||||
switch (mode) {
|
||||
case "pages":
|
||||
return {
|
||||
mode: "pages",
|
||||
text: `${currentPage}/${totalPages}`,
|
||||
};
|
||||
case "chapter":
|
||||
return {
|
||||
mode: "chapter",
|
||||
text: `${currentChapterPage}/${chapterPages}`,
|
||||
};
|
||||
case "percentage":
|
||||
const percentage = Math.round((currentPage / totalPages) * 100);
|
||||
return {
|
||||
mode: "percentage",
|
||||
text: `${percentage}%`,
|
||||
};
|
||||
case "time-left":
|
||||
if (!readingSpeed || readingSpeed.pages_per_minute === 0) {
|
||||
return { mode: "time-left", text: "--:--" };
|
||||
}
|
||||
const pagesLeft = totalPages - currentPage;
|
||||
const minutesLeft = pagesLeft / readingSpeed.pages_per_minute;
|
||||
const hours = Math.floor(minutesLeft / 60);
|
||||
const mins = Math.round(minutesLeft % 60);
|
||||
return {
|
||||
mode: "time-left",
|
||||
text: `${hours}h ${mins}m`,
|
||||
};
|
||||
default:
|
||||
return { mode: "pages", text: `${currentPage}/${totalPages}` };
|
||||
}
|
||||
}
|
||||
function cycleProgressMode(): void {
|
||||
const modes: Array<"pages" | "chapter" | "percentage" | "time-left"> = [
|
||||
"pages",
|
||||
"chapter",
|
||||
"percentage",
|
||||
"time-left",
|
||||
];
|
||||
const currentMode = getCurrentProgressMode();
|
||||
const currentIndex = modes.indexOf(currentMode);
|
||||
const nextMode = modes[(currentIndex + 1) % modes.length];
|
||||
setProgressMode(nextMode);
|
||||
}
|
||||
function getCurrentProgressMode(): ReaderSettings["progress_mode"] {
|
||||
const settingsStr = localStorage.getItem("reader_settings_progress_mode");
|
||||
if (settingsStr) {
|
||||
try {
|
||||
return JSON.parse(settingsStr);
|
||||
} catch {
|
||||
return "pages";
|
||||
}
|
||||
}
|
||||
return "pages";
|
||||
}
|
||||
function setProgressMode(mode: ReaderSettings["progress_mode"]): void {
|
||||
localStorage.setItem("reader_settings_progress_mode", JSON.stringify(mode));
|
||||
|
||||
const display = document.getElementById("progress-display");
|
||||
if (display) {
|
||||
display.dataset.mode = mode;
|
||||
}
|
||||
}
|
||||
async function getReadingSpeed(): Promise<ReadingSpeed | null> {
|
||||
const speedStr = localStorage.getItem("reader_speed_data");
|
||||
if (speedStr) {
|
||||
try {
|
||||
return JSON.parse(speedStr);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function updateProgressDisplay(context: ReaderContext): void {
|
||||
const state = context.getState();
|
||||
const display = context.elements.progressDisplay;
|
||||
if (!display || !state.currentReader) return;
|
||||
let currentPage = 0;
|
||||
let totalPages = 0;
|
||||
let currentChapterPage = 0;
|
||||
let chapterPages = 0;
|
||||
if (state.currentReader.type === "ebook") {
|
||||
const reader = state.currentReader;
|
||||
|
||||
// Use currentPage directly if available (page-based navigation)
|
||||
if (reader.currentPage) {
|
||||
currentPage = reader.currentPage;
|
||||
} else if (reader.currentSpineIndex !== undefined) {
|
||||
currentPage = reader.currentSpineIndex + 1;
|
||||
}
|
||||
|
||||
// Get total pages from calculation result
|
||||
const pageInfo = reader.pagination;
|
||||
if (pageInfo && pageInfo.totalPages > 0) {
|
||||
totalPages = pageInfo.totalPages;
|
||||
|
||||
// Get chapter progress from spine info
|
||||
const spine = pageInfo.spineMap.get(reader.currentSpineIndex);
|
||||
if (spine && spine.pages.length > 0) {
|
||||
const chapterStartPage = spine.pages[0].pageIndex;
|
||||
currentChapterPage = currentPage - chapterStartPage + 1;
|
||||
chapterPages = spine.pages.length;
|
||||
}
|
||||
} else {
|
||||
// Fallback
|
||||
totalPages =
|
||||
state.readerMetadata?.total_pages ||
|
||||
state.currentReader.cif.locations?.estimatedPages ||
|
||||
state.currentReader.cif.spine.length;
|
||||
}
|
||||
} else if (state.currentReader.type === "pdf") {
|
||||
currentPage = state.currentReader.currentPage;
|
||||
totalPages = state.readerMetadata?.total_pages || 0;
|
||||
} else {
|
||||
// Comic/manga
|
||||
const reader = state.currentReader;
|
||||
if (reader.type === "comic" || reader.type === "manga") {
|
||||
currentPage = reader.currentPage + 1;
|
||||
totalPages = reader.images?.length || 0;
|
||||
}
|
||||
}
|
||||
getReadingSpeed().then((speed) => {
|
||||
const result = calculateProgress(
|
||||
currentPage,
|
||||
totalPages,
|
||||
currentChapterPage,
|
||||
chapterPages,
|
||||
speed || undefined,
|
||||
);
|
||||
display.textContent = result.text;
|
||||
display.dataset.mode = result.mode;
|
||||
});
|
||||
}
|
||||
function setupProgressModeCycling(context: ReaderContext): void {
|
||||
const display = context.elements.progressDisplay;
|
||||
if (!display) return;
|
||||
display.style.cursor = "pointer";
|
||||
display.addEventListener("click", () => {
|
||||
cycleProgressMode();
|
||||
updateProgressDisplay(context);
|
||||
});
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
// Track reading speed and update database
|
||||
// Feature Registration Pattern implementation
|
||||
|
||||
import type { ReaderContext } from "../core/reader-context";
|
||||
|
||||
let context: ReaderContext;
|
||||
|
||||
export function init(readerContext: ReaderContext): void {
|
||||
context = readerContext;
|
||||
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;
|
||||
pagesRead: number;
|
||||
wordsRead: number;
|
||||
lastSync: number;
|
||||
mediaItemId: string;
|
||||
}
|
||||
|
||||
function createReadingSpeedTracker(
|
||||
mediaItemId: string,
|
||||
): ReadingSpeedTrackerState {
|
||||
return {
|
||||
startTime: null,
|
||||
pagesRead: 0,
|
||||
wordsRead: 0,
|
||||
lastSync: Date.now(),
|
||||
mediaItemId,
|
||||
};
|
||||
}
|
||||
|
||||
function startReadingSession(
|
||||
state: ReadingSpeedTrackerState,
|
||||
): ReadingSpeedTrackerState {
|
||||
state.startTime = Date.now();
|
||||
state.pagesRead = 0;
|
||||
state.wordsRead = 0;
|
||||
return state;
|
||||
}
|
||||
|
||||
function recordPageTurn(
|
||||
state: ReadingSpeedTrackerState,
|
||||
): ReadingSpeedTrackerState {
|
||||
if (!state.startTime) return state;
|
||||
|
||||
state.pagesRead += 1;
|
||||
const now = Date.now();
|
||||
|
||||
if (state.pagesRead % 5 === 0 || now - state.lastSync > 5 * 60 * 1000) {
|
||||
syncReadingSpeed(state);
|
||||
state.lastSync = now;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function recordWordsRead(
|
||||
state: ReadingSpeedTrackerState,
|
||||
wordCount: number,
|
||||
): ReadingSpeedTrackerState {
|
||||
state.wordsRead += wordCount;
|
||||
return state;
|
||||
}
|
||||
|
||||
async function syncReadingSpeed(
|
||||
state: ReadingSpeedTrackerState,
|
||||
): Promise<void> {
|
||||
if (!state.startTime) return;
|
||||
|
||||
const minutesElapsed = (Date.now() - state.startTime) / (1000 * 60);
|
||||
const pagesPerMinute = state.pagesRead / minutesElapsed;
|
||||
const wordsPerMinute = state.wordsRead / 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user