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