refactor(reader): remove unused feature modules

Remove obsolete feature modules that are no longer used after
the reader architecture refactor:

Comic features removed:
- background-color.ts - color picker for comic backgrounds
- chapter-markers.ts - visual chapter boundary indicators
- page-order.ts - Japanese/Western reading order detection
- panel-gap.ts - adjustable panel gap controls

Ebook features removed:
- font-loader.ts - custom font loading system
- search.ts - ebook content search functionality
- view-modes.ts - paginated/scrolled view modes

Manga features removed:
- vertical-scroll-mode.ts - webtoon vertical scroll reader

PDF features removed:
- annotation-layer.ts - highlight and note rendering
- pdf-navigation.ts - PDF page navigation and zoom
- pdf-text-selection.ts - PDF text selection handling

These features were either superseded by the new modular
architecture or were unused in the current implementation.
This commit is contained in:
2026-04-09 21:17:02 -04:00
parent 3499277c2d
commit 9fff782003
11 changed files with 0 additions and 1731 deletions
-147
View File
@@ -1,147 +0,0 @@
// Background color options for manga/comics
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
const state = createBackgroundColorState();
applyBackgroundColor(state.current);
context.events.on(
"background-color:set",
(detail: { color: BackgroundColor; customColor?: string }) => {
setBackgroundColor(state, detail.color, detail.customColor);
},
);
context.events.on("background-color:toggle", () => {
toggleBackgroundColor(state);
});
context.events.on(
"ui:show-settings",
(detail: { container: HTMLElement }) => {
renderBackgroundColorPicker(detail.container, state);
},
);
context.events.on("reader:unload", () => {
const picker = document.querySelector(".background-color-picker");
picker?.remove();
});
}
type BackgroundColor = "black" | "white" | "gray" | "sepia" | "custom";
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);
});
}
-155
View File
@@ -1,155 +0,0 @@
// Chapter markers for manga/comics
// Visual indicators for chapter boundaries
// Feature Registration Pattern implementation
import { 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 },
}),
);
}
}
-152
View File
@@ -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;
}
-160
View File
@@ -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 ? "▦" : "▢";
}
}
-97
View File
@@ -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 };
-143
View File
@@ -1,143 +0,0 @@
// Search within ebook content
// Feature Registration Pattern implementation
import { 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;
}
-174
View File
@@ -1,174 +0,0 @@
// Different viewing modes for ebooks
// Feature Registration Pattern implementation
import { ReaderContext } from "../core/reader-context";
export function init(context: ReaderContext): void {
let state: ViewModeState | null = null;
context.events.on("reader:loaded", (detail: { container: HTMLElement }) => {
state = { currentMode: "paginated", currentPage: 1 };
setViewMode(detail.container, "paginated");
});
context.events.on("view-mode:set", (detail: { mode: ViewMode }) => {
if (state && context.elements.readerContent) {
state.currentMode = detail.mode;
setViewMode(context.elements.readerContent, detail.mode);
context.events.emit("view-mode:changed", { mode: detail.mode });
}
});
context.events.on("view-mode:get", () => {
if (state) {
context.events.emit("view-mode:current", state);
}
});
context.events.on("pagination:go-to-page", (detail: { page: number }) => {
if (state && context.elements.readerContent) {
state.currentPage = detail.page;
goToPage(context.elements.readerContent, detail.page);
context.events.emit("pagination:page-changed", { page: detail.page });
}
});
}
type ViewMode = "paginated" | "scrolled" | "single-column" | "double-column";
interface ViewModeState {
currentMode: ViewMode;
currentPage: number;
}
function setViewMode(container: HTMLElement, mode: ViewMode): void {
const content = container.querySelector(".ebook-content");
if (!content) return;
const element = content as HTMLElement;
element.classList.remove(
"paginated",
"scrolled",
"single-column",
"double-column",
);
switch (mode) {
case "paginated":
applyPaginatedMode(element);
break;
case "scrolled":
applyScrolledMode(element);
break;
case "single-column":
applySingleColumn(element);
break;
case "double-column":
applyDoubleColumn(element);
break;
}
}
function applyPaginatedMode(element: HTMLElement): void {
element.classList.add("paginated");
// Set up for CSS columns
element.style.height = "calc(100vh - 120px)";
element.style.overflowY = "auto";
element.style.overflowX = "hidden";
element.style.columnCount = "1";
element.style.columnFill = "auto";
element.style.columnGap = "0";
element.style.position = "relative";
// Inject CSS for CSS column pagination
injectPaginatedStyles();
}
function injectPaginatedStyles(): void {
// Remove existing injected styles if any
const existing = document.getElementById("paginated-styles");
existing?.remove();
const style = document.createElement("style");
style.id = "paginated-styles";
style.textContent = `
.paginated {
height: calc(100vh - 120px) !important;
overflow-y: auto !important;
overflow-x: hidden !important;
column-fill: auto !important;
column-count: 1 !important;
column-gap: 0 !important;
position: relative !important;
}
.paginated > * {
max-width: 100%;
column-fill: auto;
}
.paginated .ebook-content {
height: auto !important;
min-height: 100%;
}
`;
document.head.appendChild(style);
}
function applyScrolledMode(element: HTMLElement): void {
element.classList.add("scrolled");
element.style.height = "auto";
element.style.overflowY = "auto";
element.style.columnCount = "1";
}
function applySingleColumn(element: HTMLElement): void {
element.classList.add("single-column");
element.style.columnCount = "1";
element.style.columnGap = "0";
element.style.maxWidth = "800px";
element.style.margin = "0 auto";
}
function applyDoubleColumn(element: HTMLElement): void {
element.classList.add("double-column");
element.style.columnCount = "2";
element.style.columnGap = "60px";
element.style.columnRule = "1px solid var(--text-secondary)";
element.style.maxWidth = "1400px";
element.style.margin = "0 auto";
}
function goToPage(container: HTMLElement, pageNumber: number): void {
const content = container.querySelector(".ebook-content") as HTMLElement;
if (!content) return;
const totalPages = getTotalPageCount(container);
const targetPage = Math.min(Math.max(1, pageNumber), totalPages);
const viewportHeight = window.innerHeight - 120;
const scrollTop = (targetPage - 1) * viewportHeight;
content.scrollTo({
top: scrollTop,
behavior: "smooth",
});
}
function getTotalPageCount(container: HTMLElement): number {
const content = container.querySelector(".ebook-content") as HTMLElement;
if (!content) return 1;
const viewportHeight = window.innerHeight - 120;
const contentHeight = content.scrollHeight;
return Math.max(1, Math.ceil(contentHeight / viewportHeight));
}
export function getCurrentViewMode(container: HTMLElement): ViewMode {
const content = container.querySelector(".ebook-content") as HTMLElement;
if (!content) return "paginated";
if (content.classList.contains("paginated")) return "paginated";
if (content.classList.contains("scrolled")) return "scrolled";
if (content.classList.contains("single-column")) return "single-column";
if (content.classList.contains("double-column")) return "double-column";
return "paginated";
}
@@ -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();
}
-123
View File
@@ -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);
}
}
-215
View File
@@ -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;
}
-165
View File
@@ -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;
}