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);
-- 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)