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);
|
CREATE INDEX idx_reader_settings_user ON reader_settings(user_id);
|
||||||
|
|
||||||
-- PDF bookmarks (custom user bookmarks)
|
-- PDF bookmarks (custom user bookmarks)
|
||||||
CREATE TABLE pdf_bookmarks (
|
CREATE TABLE media_bookmarks (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||||
user_id UUID NOT NULL REFERENCES users(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,
|
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(),
|
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_media_bookmarks_media ON media_bookmarks(media_item_id);
|
||||||
CREATE INDEX idx_pdf_bookmarks_user ON pdf_bookmarks(user_id);
|
CREATE INDEX idx_media_bookmarks_user ON media_bookmarks(user_id);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 Alter Existing Tables
|
### 2.2 Alter Existing Tables
|
||||||
@@ -693,7 +695,7 @@ interface ReaderSettings {
|
|||||||
pdf_dual_page_threshold: number;
|
pdf_dual_page_threshold: number;
|
||||||
pdf_minimap_enabled: boolean;
|
pdf_minimap_enabled: boolean;
|
||||||
pdf_outline_visible: boolean;
|
pdf_outline_visible: boolean;
|
||||||
pdf_bookmarks_visible: boolean;
|
media_bookmarks_visible: boolean;
|
||||||
|
|
||||||
hardware_acceleration: boolean;
|
hardware_acceleration: boolean;
|
||||||
}
|
}
|
||||||
@@ -5623,7 +5625,7 @@ function findOutlineNode(
|
|||||||
// Custom bookmarks for PDF pages (saved in database)
|
// Custom bookmarks for PDF pages (saved in database)
|
||||||
// Procedural implementation (no OOP)
|
// Procedural implementation (no OOP)
|
||||||
|
|
||||||
interface PDFBookmark {
|
interface MediaBookmark {
|
||||||
id: string;
|
id: string;
|
||||||
mediaItemId: string;
|
mediaItemId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -5632,19 +5634,19 @@ interface PDFBookmark {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PDFBookmarksState {
|
interface MediaBookmarksState {
|
||||||
mediaItemId: string;
|
mediaItemId: string;
|
||||||
bookmarks: PDFBookmark[];
|
bookmarks: MediaBookmark[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPDFBookmarks(mediaItemId: string): PDFBookmarksState {
|
function createMediaBookmarks(mediaItemId: string): MediaBookmarksState {
|
||||||
return {
|
return {
|
||||||
mediaItemId,
|
mediaItemId,
|
||||||
bookmarks: []
|
bookmarks: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPDFBookmarks(state: PDFBookmarksState): Promise<PDFBookmarksState> {
|
async function loadMediaBookmarks(state: MediaBookmarksState): Promise<MediaBookmarksState> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks`);
|
const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks`);
|
||||||
if (!response.ok) throw new Error('Failed to load 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(
|
async function addMediaBookmark(
|
||||||
state: PDFBookmarksState,
|
state: MediaBookmarksState,
|
||||||
pageNumber: number,
|
pageNumber: number,
|
||||||
title?: string
|
title?: string
|
||||||
): Promise<PDFBookmarksState & { bookmark: PDFBookmark }> {
|
): Promise<MediaBookmarksState & { bookmark: MediaBookmark }> {
|
||||||
const bookmark: PDFBookmark = {
|
const bookmark: MediaBookmark = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
mediaItemId: state.mediaItemId,
|
mediaItemId: state.mediaItemId,
|
||||||
userId: '',
|
userId: '',
|
||||||
@@ -5697,10 +5699,10 @@ async function addPDFBookmark(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removePDFBookmark(
|
async function removeMediaBookmark(
|
||||||
state: PDFBookmarksState,
|
state: MediaBookmarksState,
|
||||||
bookmarkId: string
|
bookmarkId: string
|
||||||
): Promise<PDFBookmarksState> {
|
): Promise<MediaBookmarksState> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks/${bookmarkId}`, {
|
const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks/${bookmarkId}`, {
|
||||||
method: 'DELETE'
|
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);
|
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);
|
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;
|
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
|
## 8. Lazy Loading & Caching
|
||||||
@@ -7689,7 +8509,7 @@ This implementation plan provides a comprehensive roadmap for building a modern,
|
|||||||
**Next steps:**
|
**Next steps:**
|
||||||
1. Review and approve this plan
|
1. Review and approve this plan
|
||||||
2. Begin Phase 1: Infrastructure & Basic Reader
|
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
|
4. Implement parser manager and CIF types
|
||||||
5. Build universal reader shell (procedural style)
|
5. Build universal reader shell (procedural style)
|
||||||
6. Implement parsers (start with EPUB, TXT - simplest first)
|
6. Implement parsers (start with EPUB, TXT - simplest first)
|
||||||
|
|||||||
Reference in New Issue
Block a user