docs: comprehensive reader implementation plan updates
Major changes: - Convert all 22 OOP classes to procedural TypeScript functions - Add complete PDF reader implementation (15 subsections, 6.1-6.15) - Add 8 technical textbook features for PDFs (TOC, bookmarks, clipboard, dual-page, mini-map, rotation, variable page sizes) - Remove duplicate sections and fix section numbering (1-19 sequential) - Add 7 new comic/manga features (7.4-7.10): * Page order presets with auto-detect and user override * Extended keyboard shortcuts (Space, PageUp/Down, +/-, B, F, 1-9) * Page slider/scrubber for quick navigation * Touch gesture controls (swipe, pinch-to-zoom, tap, double-tap) * Panel gap controls with adjustable spacing and borders * Background color options (black/white/gray/sepia) * Chapter markers with visual boundaries Architecture improvements: - Universal reader + pluggable parser pipeline (all ebooks → CIF) - Client-side parsers: EPUB, FB2, TXT, HTML - Server-side parsers: MOBI, AZW3, DOCX, RTF (Go backend) - ~500 KB total dependencies vs 182 MB Calibre Database schema updates: - Rename pdf_bookmarks to media_bookmarks - Add chapter_number and cfi_position fields for all media types - Unified bookmarks for ebooks, comics, manga, and PDFs Preserved features: - 8 bundled libre reading fonts with complete setup scripts - 5 reading-optimized themes (light, sepia, dark, night, high-contrast) - Hybrid theming strategy (11 themes for UI, 5 for ebook text) - Panel detection (grid-based + ML + manual override) - All 4 media types: ebook, comic, manga, PDF All code follows PROJECT_GUIDELINES.md: - No OOP (no classes, no this-capture) - Procedural/imperative style - Backend handles all position calculations (CFI, character offsets, etc.) - Frontend receives positions from backend, no duplication
This commit is contained in:
+843
-23
@@ -336,19 +336,21 @@ CREATE TABLE reader_settings (
|
||||
CREATE INDEX idx_reader_settings_user ON reader_settings(user_id);
|
||||
|
||||
-- PDF bookmarks (custom user bookmarks)
|
||||
CREATE TABLE pdf_bookmarks (
|
||||
CREATE TABLE media_bookmarks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
page_number INTEGER NOT NULL,
|
||||
page_number INTEGER,
|
||||
chapter_number INTEGER,
|
||||
cfi_position VARCHAR(255), -- For ebooks: EPUB CFI position
|
||||
title VARCHAR(255) NOT NULL,
|
||||
position VARCHAR(100), -- 'pdf:page:45' for consistency
|
||||
position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(media_item_id, user_id, page_number)
|
||||
UNIQUE(media_item_id, user_id, COALESCE(page_number, 0), COALESCE(chapter_number, 0))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_pdf_bookmarks_media ON pdf_bookmarks(media_item_id);
|
||||
CREATE INDEX idx_pdf_bookmarks_user ON pdf_bookmarks(user_id);
|
||||
CREATE INDEX idx_media_bookmarks_media ON media_bookmarks(media_item_id);
|
||||
CREATE INDEX idx_media_bookmarks_user ON media_bookmarks(user_id);
|
||||
```
|
||||
|
||||
### 2.2 Alter Existing Tables
|
||||
@@ -693,7 +695,7 @@ interface ReaderSettings {
|
||||
pdf_dual_page_threshold: number;
|
||||
pdf_minimap_enabled: boolean;
|
||||
pdf_outline_visible: boolean;
|
||||
pdf_bookmarks_visible: boolean;
|
||||
media_bookmarks_visible: boolean;
|
||||
|
||||
hardware_acceleration: boolean;
|
||||
}
|
||||
@@ -5623,7 +5625,7 @@ function findOutlineNode(
|
||||
// Custom bookmarks for PDF pages (saved in database)
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface PDFBookmark {
|
||||
interface MediaBookmark {
|
||||
id: string;
|
||||
mediaItemId: string;
|
||||
userId: string;
|
||||
@@ -5632,19 +5634,19 @@ interface PDFBookmark {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface PDFBookmarksState {
|
||||
interface MediaBookmarksState {
|
||||
mediaItemId: string;
|
||||
bookmarks: PDFBookmark[];
|
||||
bookmarks: MediaBookmark[];
|
||||
}
|
||||
|
||||
function createPDFBookmarks(mediaItemId: string): PDFBookmarksState {
|
||||
function createMediaBookmarks(mediaItemId: string): MediaBookmarksState {
|
||||
return {
|
||||
mediaItemId,
|
||||
bookmarks: []
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPDFBookmarks(state: PDFBookmarksState): Promise<PDFBookmarksState> {
|
||||
async function loadMediaBookmarks(state: MediaBookmarksState): Promise<MediaBookmarksState> {
|
||||
try {
|
||||
const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks`);
|
||||
if (!response.ok) throw new Error('Failed to load bookmarks');
|
||||
@@ -5657,12 +5659,12 @@ async function loadPDFBookmarks(state: PDFBookmarksState): Promise<PDFBookmarksS
|
||||
}
|
||||
}
|
||||
|
||||
async function addPDFBookmark(
|
||||
state: PDFBookmarksState,
|
||||
async function addMediaBookmark(
|
||||
state: MediaBookmarksState,
|
||||
pageNumber: number,
|
||||
title?: string
|
||||
): Promise<PDFBookmarksState & { bookmark: PDFBookmark }> {
|
||||
const bookmark: PDFBookmark = {
|
||||
): Promise<MediaBookmarksState & { bookmark: MediaBookmark }> {
|
||||
const bookmark: MediaBookmark = {
|
||||
id: crypto.randomUUID(),
|
||||
mediaItemId: state.mediaItemId,
|
||||
userId: '',
|
||||
@@ -5697,10 +5699,10 @@ async function addPDFBookmark(
|
||||
}
|
||||
}
|
||||
|
||||
async function removePDFBookmark(
|
||||
state: PDFBookmarksState,
|
||||
async function removeMediaBookmark(
|
||||
state: MediaBookmarksState,
|
||||
bookmarkId: string
|
||||
): Promise<PDFBookmarksState> {
|
||||
): Promise<MediaBookmarksState> {
|
||||
try {
|
||||
const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks/${bookmarkId}`, {
|
||||
method: 'DELETE'
|
||||
@@ -5718,15 +5720,15 @@ async function removePDFBookmark(
|
||||
}
|
||||
}
|
||||
|
||||
function getPDFBookmarks(state: PDFBookmarksState): PDFBookmark[] {
|
||||
function getMediaBookmarks(state: MediaBookmarksState): MediaBookmark[] {
|
||||
return [...state.bookmarks].sort((a, b) => a.pageNumber - b.pageNumber);
|
||||
}
|
||||
|
||||
function hasPDFBookmarkAt(state: PDFBookmarksState, pageNumber: number): boolean {
|
||||
function hasMediaBookmarkAt(state: MediaBookmarksState, pageNumber: number): boolean {
|
||||
return state.bookmarks.some(b => b.pageNumber === pageNumber);
|
||||
}
|
||||
|
||||
function getPDFBookmarkAt(state: PDFBookmarksState, pageNumber: number): PDFBookmark | null {
|
||||
function getMediaBookmarkAt(state: MediaBookmarksState, pageNumber: number): MediaBookmark | null {
|
||||
return state.bookmarks.find(b => b.pageNumber === pageNumber) || null;
|
||||
}
|
||||
```
|
||||
@@ -6717,6 +6719,824 @@ async function saveManualPanel(panel: Panel): Promise<void> {
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 Page Order Presets
|
||||
|
||||
**File:** `web/src/reader/comic/page-order.ts`
|
||||
|
||||
```typescript
|
||||
// Page order presets for manga/comics
|
||||
// Auto-detect Japanese vs Western reading order
|
||||
// Allow user override in case detection is wrong
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
type PageOrderMode = 'auto' | 'japanese' | 'western';
|
||||
|
||||
interface PageOrderConfig {
|
||||
mode: PageOrderMode;
|
||||
detectedOrder: PageOrderMode;
|
||||
userOverride: boolean;
|
||||
}
|
||||
|
||||
interface PageOrderState {
|
||||
config: PageOrderConfig;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// Detect page order based on filename patterns
|
||||
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): PageOrderState {
|
||||
return {
|
||||
...state,
|
||||
config: {
|
||||
...state.config,
|
||||
mode,
|
||||
userOverride: mode !== 'auto'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
### 7.5 Extended Keyboard Shortcuts
|
||||
|
||||
**File:** `web/src/reader/keyboard-shortcuts.ts`
|
||||
|
||||
```typescript
|
||||
// Extended keyboard shortcuts for all readers
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface KeyboardShortcutHandler {
|
||||
onNextPage: () => void;
|
||||
onPreviousPage: () => void;
|
||||
onNextChapter: () => void;
|
||||
onPreviousChapter: () => void;
|
||||
onGoToPage: (page: number) => void;
|
||||
onToggleBookmark: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function setupKeyboardShortcuts(
|
||||
container: HTMLElement,
|
||||
handlers: KeyboardShortcutHandler,
|
||||
maxPage: number
|
||||
): void {
|
||||
container.addEventListener('keydown', (e) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'PageDown':
|
||||
case 'l':
|
||||
e.preventDefault();
|
||||
handlers.onNextPage();
|
||||
break;
|
||||
|
||||
case 'ArrowLeft':
|
||||
case 'PageUp':
|
||||
case 'h':
|
||||
e.preventDefault();
|
||||
handlers.onPreviousPage();
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
handlers.onPreviousPage();
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
case 'j':
|
||||
e.preventDefault();
|
||||
handlers.onNextPage();
|
||||
break;
|
||||
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
handlers.onNextPage();
|
||||
break;
|
||||
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
handlers.onGoToPage(1);
|
||||
break;
|
||||
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
handlers.onGoToPage(maxPage);
|
||||
break;
|
||||
|
||||
case 'b':
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
handlers.onToggleBookmark();
|
||||
}
|
||||
break;
|
||||
|
||||
case '+':
|
||||
case '=':
|
||||
e.preventDefault();
|
||||
handlers.onZoomIn();
|
||||
break;
|
||||
|
||||
case '-':
|
||||
case '_':
|
||||
e.preventDefault();
|
||||
handlers.onZoomOut();
|
||||
break;
|
||||
|
||||
case '0':
|
||||
e.preventDefault();
|
||||
handlers.onZoomIn();
|
||||
handlers.onZoomIn();
|
||||
handlers.onZoomIn();
|
||||
break;
|
||||
|
||||
case 'f':
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
handlers.onToggleFullscreen();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
handlers.onClose();
|
||||
break;
|
||||
|
||||
default:
|
||||
if (e.key >= '1' && e.key <= '9') {
|
||||
const targetPage = Math.floor((parseInt(e.key) / 10) * maxPage);
|
||||
e.preventDefault();
|
||||
handlers.onGoToPage(targetPage);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showShortcutHelp(): void {
|
||||
const help = document.createElement('div');
|
||||
help.className = 'keyboard-shortcut-help fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50';
|
||||
help.innerHTML = `
|
||||
<div class="bg-gray-800 rounded-lg p-6 max-w-md">
|
||||
<h2 class="text-xl font-bold mb-4">Keyboard Shortcuts</h2>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div><kbd class="bg-gray-700 px-2 py-1 rounded">→</kbd> / <kbd class="bg-gray-700 px-2 py-1 rounded">Space</kbd> Next page</div>
|
||||
<div><kbd class="bg-gray-700 px-2 py-1 rounded">←</kbd> Previous page</div>
|
||||
<div><kbd class="bg-gray-700 px-2 py-1 rounded">Home</kbd> First page</div>
|
||||
<div><kbd class="bg-gray-700 px-2 py-1 rounded">End</kbd> Last page</div>
|
||||
<div><kbd class="bg-gray-700 px-2 py-1 rounded">+</kbd> / <kbd class="bg-gray-700 px-2 py-1 rounded">-</kbd> Zoom</div>
|
||||
<div><kbd class="bg-gray-700 px-2 py-1 rounded">B</kbd> Toggle bookmark</div>
|
||||
<div><kbd class="bg-gray-700 px-2 py-1 rounded">F</kbd> Fullscreen</div>
|
||||
<div><kbd class="bg-gray-700 px-2 py-1 rounded">1-9</kbd> Jump to %</div>
|
||||
</div>
|
||||
<button class="mt-4 px-4 py-2 bg-blue-600 rounded" onclick="this.closest('.keyboard-shortcut-help').remove()">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(help);
|
||||
help.addEventListener('click', (e) => {
|
||||
if (e.target === help) help.remove();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 7.6 Page Slider/Scrubber
|
||||
|
||||
**File:** `web/src/reader/comic/page-scrubber.ts`
|
||||
|
||||
```typescript
|
||||
// Page slider/scrubber for quick navigation
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface PageScrubberState {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
container: HTMLElement;
|
||||
}
|
||||
|
||||
function createPageScrubber(
|
||||
container: HTMLElement,
|
||||
currentPage: number,
|
||||
totalPages: number
|
||||
): PageScrubberState {
|
||||
const state: PageScrubberState = {
|
||||
currentPage,
|
||||
totalPages,
|
||||
container
|
||||
};
|
||||
|
||||
renderPageScrubber(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function renderPageScrubber(state: PageScrubberState): void {
|
||||
const existing = state.container.querySelector('.page-scrubber');
|
||||
existing?.remove();
|
||||
|
||||
const scrubber = document.createElement('div');
|
||||
scrubber.className = 'page-scrubber fixed bottom-20 left-1/2 transform -translate-x-1/2 bg-gray-900 bg-opacity-90 rounded-full px-4 py-2 flex items-center gap-4 z-40';
|
||||
scrubber.innerHTML = `
|
||||
<span class="page-label">${state.currentPage}</span>
|
||||
<input
|
||||
type="range"
|
||||
class="page-slider w-64 h-2 bg-gray-700 rounded-full appearance-none cursor-pointer"
|
||||
min="1"
|
||||
max="${state.totalPages}"
|
||||
value="${state.currentPage}"
|
||||
/>
|
||||
<span class="page-total">${state.totalPages}</span>
|
||||
`;
|
||||
|
||||
const slider = scrubber.querySelector('.page-slider') as HTMLInputElement;
|
||||
slider.addEventListener('input', (e) => {
|
||||
const targetPage = parseInt((e.target as HTMLInputElement).value);
|
||||
updatePageScrubber(state, targetPage);
|
||||
});
|
||||
|
||||
slider.addEventListener('change', () => {
|
||||
const targetPage = parseInt(slider.value);
|
||||
navigateToPage(targetPage);
|
||||
});
|
||||
|
||||
state.container.appendChild(scrubber);
|
||||
}
|
||||
|
||||
function updatePageScrubber(state: PageScrubberState, currentPage: number): PageScrubberState {
|
||||
const newState = { ...state, currentPage };
|
||||
|
||||
const label = state.container.querySelector('.page-label');
|
||||
if (label) {
|
||||
label.textContent = String(currentPage);
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
function showPageScrubber(state: PageScrubberState): void {
|
||||
const scrubber = state.container.querySelector('.page-scrubber');
|
||||
scrubber?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hidePageScrubber(state: PageScrubberState): void {
|
||||
const scrubber = state.container.querySelector('.page-scrubber');
|
||||
scrubber?.classList.add('hidden');
|
||||
}
|
||||
|
||||
function navigateToPage(page: number): void {
|
||||
window.dispatchEvent(new CustomEvent('navigate-to-page', { detail: { page } }));
|
||||
}
|
||||
```
|
||||
|
||||
### 7.7 Gesture Controls
|
||||
|
||||
**File:** `web/src/reader/gestures.ts`
|
||||
|
||||
```typescript
|
||||
// Touch gesture controls for mobile/tablet
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface GestureHandlers {
|
||||
onSwipeLeft: () => void;
|
||||
onSwipeRight: () => void;
|
||||
onSwipeUp: () => void;
|
||||
onSwipeDown: () => void;
|
||||
onPinch: (scale: number) => void;
|
||||
onTap: () => void;
|
||||
onDoubleTap: () => void;
|
||||
}
|
||||
|
||||
interface GestureState {
|
||||
touchStartX: number;
|
||||
touchStartY: number;
|
||||
touchStartTime: number;
|
||||
lastTapTime: number;
|
||||
initialPinchDistance: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
function setupGestureControls(
|
||||
container: HTMLElement,
|
||||
handlers: GestureHandlers
|
||||
): void {
|
||||
let state: GestureState = {
|
||||
touchStartX: 0,
|
||||
touchStartY: 0,
|
||||
touchStartTime: 0,
|
||||
lastTapTime: 0,
|
||||
initialPinchDistance: 0,
|
||||
scale: 1
|
||||
};
|
||||
|
||||
container.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length === 1) {
|
||||
state.touchStartX = e.touches[0].clientX;
|
||||
state.touchStartY = e.touches[0].clientY;
|
||||
state.touchStartTime = Date.now();
|
||||
} else if (e.touches.length === 2) {
|
||||
state.initialPinchDistance = getPinchDistance(e.touches);
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
container.addEventListener('touchend', (e) => {
|
||||
const deltaX = e.changedTouches[0].clientX - state.touchStartX;
|
||||
const deltaY = e.changedTouches[0].clientY - state.touchStartY;
|
||||
const deltaTime = Date.now() - state.touchStartTime;
|
||||
|
||||
if (Math.abs(deltaX) < 30 && Math.abs(deltaY) < 30 && deltaTime < 300) {
|
||||
const now = Date.now();
|
||||
if (now - state.lastTapTime < 300) {
|
||||
handlers.onDoubleTap();
|
||||
state.lastTapTime = 0;
|
||||
} else {
|
||||
state.lastTapTime = now;
|
||||
setTimeout(() => {
|
||||
if (state.lastTapTime !== 0) {
|
||||
handlers.onTap();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const minSwipeDistance = 50;
|
||||
const maxSwipeTime = 500;
|
||||
|
||||
if (deltaTime > maxSwipeTime) return;
|
||||
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
if (deltaX > minSwipeDistance) {
|
||||
handlers.onSwipeRight();
|
||||
} else if (deltaX < -minSwipeDistance) {
|
||||
handlers.onSwipeLeft();
|
||||
}
|
||||
} else {
|
||||
if (deltaY > minSwipeDistance) {
|
||||
handlers.onSwipeDown();
|
||||
} else if (deltaY < -minSwipeDistance) {
|
||||
handlers.onSwipeUp();
|
||||
}
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
container.addEventListener('touchmove', (e) => {
|
||||
if (e.touches.length === 2) {
|
||||
const currentDistance = getPinchDistance(e.touches);
|
||||
if (state.initialPinchDistance > 0) {
|
||||
const scale = currentDistance / state.initialPinchDistance;
|
||||
state.scale = scale;
|
||||
handlers.onPinch(scale);
|
||||
}
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
function getPinchDistance(touches: TouchList): number {
|
||||
const dx = touches[0].clientX - touches[1].clientX;
|
||||
const dy = touches[0].clientY - touches[1].clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
```
|
||||
|
||||
### 7.8 Panel Gap Controls
|
||||
|
||||
**File:** `web/src/reader/comic/panel-gap.ts`
|
||||
|
||||
```typescript
|
||||
// Adjustable panel gap controls
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
interface PanelGapState {
|
||||
gapSize: number;
|
||||
showBorders: boolean;
|
||||
}
|
||||
|
||||
function createPanelGapState(initialGap: number = 4): PanelGapState {
|
||||
return {
|
||||
gapSize: initialGap,
|
||||
showBorders: false
|
||||
};
|
||||
}
|
||||
|
||||
function setPanelGap(state: PanelGapState, gap: number): PanelGapState {
|
||||
const clampedGap = Math.max(0, Math.min(20, gap));
|
||||
|
||||
document.documentElement.style.setProperty('--panel-gap', `${clampedGap}px`);
|
||||
|
||||
return { ...state, gapSize: clampedGap };
|
||||
}
|
||||
|
||||
function 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 {
|
||||
const newShowBorders = !state.showBorders;
|
||||
|
||||
document.documentElement.style.setProperty(
|
||||
'--panel-border-width',
|
||||
newShowBorders ? '1px' : '0px'
|
||||
);
|
||||
|
||||
return { ...state, showBorders: newShowBorders };
|
||||
}
|
||||
|
||||
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', () => {
|
||||
const newState = increasePanelGap(state);
|
||||
updatePanelGapUI(controls, newState);
|
||||
});
|
||||
|
||||
controls.querySelector('.panel-gap-decrease')?.addEventListener('click', () => {
|
||||
const newState = decreasePanelGap(state);
|
||||
updatePanelGapUI(controls, newState);
|
||||
});
|
||||
|
||||
controls.querySelector('.panel-gap-borders')?.addEventListener('click', () => {
|
||||
const newState = togglePanelBorders(state);
|
||||
updatePanelGapUI(controls, newState);
|
||||
});
|
||||
|
||||
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 ? '▦' : '▢';
|
||||
}
|
||||
}
|
||||
|
||||
const panelGapCSS = `
|
||||
:root {
|
||||
--panel-gap: 4px;
|
||||
--panel-border-width: 0px;
|
||||
}
|
||||
|
||||
.panel-zoom-container {
|
||||
gap: var(--panel-gap);
|
||||
}
|
||||
|
||||
.panel-zoom-container.with-borders {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: var(--panel-gap);
|
||||
}
|
||||
|
||||
.panel-borders {
|
||||
border: var(--panel-border-width) dashed rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
### 7.9 Background Color Options
|
||||
|
||||
**File:** `web/src/reader/comic/background-color.ts`
|
||||
|
||||
```typescript
|
||||
// Background color options for manga/comics
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
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 {
|
||||
return {
|
||||
current: initial,
|
||||
customColor: '#000000'
|
||||
};
|
||||
}
|
||||
|
||||
function setBackgroundColor(
|
||||
state: BackgroundColorState,
|
||||
color: BackgroundColor,
|
||||
customColor?: string
|
||||
): BackgroundColorState {
|
||||
const newState: BackgroundColorState = {
|
||||
current: color,
|
||||
customColor: customColor || state.customColor
|
||||
};
|
||||
|
||||
const bgColor = color === 'custom'
|
||||
? newState.customColor
|
||||
: backgroundColors[color];
|
||||
|
||||
document.documentElement.style.setProperty('--reader-bg-color', bgColor);
|
||||
|
||||
const viewer = document.querySelector('.reader-content');
|
||||
if (viewer) {
|
||||
viewer.style.backgroundColor = bgColor;
|
||||
}
|
||||
|
||||
localStorage.setItem('reader-background-color', color);
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
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', () => {
|
||||
const newState = setBackgroundColor(state, color);
|
||||
updateBackgroundColorUI(picker, newState);
|
||||
});
|
||||
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);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 7.10 Chapter Markers
|
||||
|
||||
**File:** `web/src/reader/comic/chapter-markers.ts`
|
||||
|
||||
```typescript
|
||||
// Chapter markers for manga/comics
|
||||
// Visual indicators for chapter boundaries
|
||||
// Procedural implementation (no OOP)
|
||||
|
||||
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) {
|
||||
const newState = { ...state, currentChapter };
|
||||
|
||||
const markers = document.querySelector('.chapter-markers');
|
||||
if (markers) {
|
||||
renderChapterMarkers(markers.parentElement!, newState);
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function toggleChapterMarkers(state: ChapterMarkerState): ChapterMarkerState {
|
||||
const newState = { ...state, showMarkers: !state.showMarkers };
|
||||
|
||||
const markers = document.querySelector('.chapter-markers');
|
||||
if (markers) {
|
||||
markers.classList.toggle('hidden', !newState.showMarkers);
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
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 }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const chapterMarkerCSS = `
|
||||
.chapter-marker {
|
||||
padding: 4px 8px;
|
||||
margin-left: -18px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.chapter-marker:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chapter-marker .current-indicator {
|
||||
color: #3b82f6;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.chapter-marker-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, rgba(255,255,255,0.1), transparent);
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Lazy Loading & Caching
|
||||
@@ -7689,7 +8509,7 @@ This implementation plan provides a comprehensive roadmap for building a modern,
|
||||
**Next steps:**
|
||||
1. Review and approve this plan
|
||||
2. Begin Phase 1: Infrastructure & Basic Reader
|
||||
3. Create database schema (add pdf_bookmarks table)
|
||||
3. Create database schema (add media_bookmarks table with chapter_number and cfi_position fields)
|
||||
4. Implement parser manager and CIF types
|
||||
5. Build universal reader shell (procedural style)
|
||||
6. Implement parsers (start with EPUB, TXT - simplest first)
|
||||
|
||||
Reference in New Issue
Block a user