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:
2026-04-02 16:58:45 -04:00
parent 9dd217afb6
commit 925228f7cf
+843 -23
View File
@@ -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)