Added Section 8: Manga Reader Implementation with 5 subsections: 8.1 RTL Navigator (Right-to-Left Reading) - Reverse page turn direction for traditional manga - Proper key bindings for RTL navigation - Progress tracking adapted for RTL 8.2 Vertical Scroll Mode (Webtoon Style) - Infinite vertical scroll for webtoons/manhwa - Lazy loading with threshold-based prefetching - Scroll position to page number mapping - Memory-efficient blob URL cleanup 8.3 Reading Direction Detection - Auto-detection from metadata (manga_type, reading_direction) - Filename-based heuristics (manga, manhwa, webtoon) - User preference support with fallback - Integration with database schema 8.4 Manga Settings Integration - Reading direction preference (auto/ltr/rtl/vertical) - Vertical scroll speed control - RTL page transition effects - Settings persistence via API 8.5 Manga Page Cache (shared with comics) - 5-page ahead prefetching - Memory management with cleanup - Shared caching strategy for comics and manga This fills the gap where manga was architecturally planned but had no implementation details. Database schema, types, and UI already supported manga - now the implementation is documented. Plan now has complete coverage for all 4 media types: ebook, comic, manga, pdf
11266 lines
327 KiB
Markdown
11266 lines
327 KiB
Markdown
<!-- markdownlint-disable MD013 -->
|
|
|
|
# 📖 Bookhoard Reader Implementation Plan
|
|
|
|
## Overview
|
|
|
|
Build a modern, responsive web reader for ebooks, comics, manga, and PDFs with full feature parity across all four media types.
|
|
|
|
**Design Philosophy:**
|
|
- **Universal reader architecture**: One rendering engine with pluggable parser components
|
|
- **Common Intermediate Format (CIF)**: All reflowable ebooks convert to standardized HTML structure
|
|
- **Hybrid parsing strategy**: Server-side for complex formats (MOBI, AZW3, DOCX), client-side for simple formats (EPUB, FB2, TXT)
|
|
- **Procedural TypeScript**: No OOP, no classes, functional techniques where helpful (per PROJECT_GUIDELINES.md)
|
|
- **Surgical code reuse**: Leverage existing WebSocket sync, progress tracking, annotation systems
|
|
- **Progressive enhancement**: SSR-first with TypeScript enhancements
|
|
- **Privacy-first**: Per-user settings with localStorage fallback
|
|
- **Offline-capable**: PWA with offline dictionary
|
|
- **Full PDF support**: Mozilla pdf.js for text selection, highlights, search
|
|
- **Technical textbook optimization**: TOC navigation, bookmarks, dual-page view, navigator, copy support
|
|
- **Modular dockable panels**: TOC, Settings, Navigator, Bookmarks - each independent, dockable to either side, window-shade support
|
|
|
|
## What's New in This Version
|
|
|
|
### **Major Architecture Change: Universal Reader + Parsers**
|
|
|
|
**Previous approach:** Separate readers for each format (EbookReader, ComicReader, etc.)
|
|
|
|
**New approach:** Single universal reader with parser pipeline
|
|
|
|
```
|
|
All Reflowable Ebooks → Parse to CIF → Universal Reader
|
|
├── EPUB → EPUBParser → CIF → Universal Reader
|
|
├── FB2 → FB2Parser → CIF → Universal Reader
|
|
├── TXT → TXTParser → CIF → Universal Reader
|
|
├── HTML → HTMLParser → CIF → Universal Reader
|
|
├── MOBI → Server Parser → CIF → Universal Reader
|
|
├── AZW3 → Server Parser → CIF → Universal Reader
|
|
├── DOCX → Server Parser → CIF → Universal Reader
|
|
└── RTF → Server Parser → CIF → Universal Reader
|
|
```
|
|
|
|
**Benefits:**
|
|
- One codebase for UI/UX (fix once, works for all formats)
|
|
- Easy to add new formats (just implement parser interface)
|
|
- Consistent user experience across all ebooks
|
|
- ~500 KB total dependency size (vs. 182 MB Calibre)
|
|
|
|
### **Procedural TypeScript (No OOP)**
|
|
|
|
All code follows PROJECT_GUIDELINES.md:
|
|
- ❌ No classes
|
|
- ❌ No `this` capture
|
|
- ❌ No inheritance
|
|
- ✅ Functions and modules
|
|
- ✅ Functional techniques where helpful
|
|
- ✅ Procedural/imperative style
|
|
|
|
**Example:**
|
|
|
|
```typescript
|
|
// ❌ OLD (OOP - not allowed)
|
|
class EPUBParser {
|
|
private zip: JSZip | null = null;
|
|
async parse(blob: Blob): Promise<CIF> { ... }
|
|
}
|
|
|
|
// ✅ NEW (Procedural - correct)
|
|
export async function parseEPUB(blob: Blob): Promise<CIF> { ... }
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 0: Prerequisites & Setup ⚠️ **MUST COMPLETE FIRST**
|
|
|
|
**IMPORTANT:** Do not start implementation until these prerequisites are complete. Failure to complete these steps will result in compilation errors and missing functionality.
|
|
|
|
### 0.1 Database Schema Setup
|
|
|
|
**Step 1:** The database tables are already defined in **Section 2.1** of this document. Copy the SQL from **Section 2.1 (lines 563-641)** and add it to:
|
|
|
|
**File:** `database/schema/schema.sql` (modify existing)
|
|
|
|
Add the SQL at the end of the file, before the index section (around line 1105).
|
|
|
|
**Step 2:** Also add the ALTER TABLE statement from **Section 2.2** to add `chapter_metadata` column to `media_items` table.
|
|
|
|
**Step 3:** After adding the tables, regenerate database queries:
|
|
|
|
```bash
|
|
cd /home/nymusicman/Code/bookhoard/database
|
|
sqlc generate
|
|
```
|
|
|
|
**Step 4:** Update your local database:
|
|
|
|
**Option 1: Recreate database (LOSES ALL DATA - Recommended for development):**
|
|
```bash
|
|
podman compose down -v # Delete all volumes
|
|
podman compose up -d # Start with fresh schema
|
|
```
|
|
|
|
**Option 2: Manual SQL migration (preserves data):**
|
|
```bash
|
|
podman exec bookhoard_db psql -U postgres -d bookhoard
|
|
# Then paste the SQL from Section 2.1 and 2.2
|
|
```
|
|
|
|
### 0.2 Database Queries Setup
|
|
|
|
**File:** `internal/database/queries/queries.sql` (modify existing)
|
|
|
|
Add these queries to support the reader functionality:
|
|
|
|
```sql
|
|
-- name: GetPanelData :one
|
|
SELECT * FROM panel_data
|
|
WHERE media_item_id = $1 AND page_number = $2;
|
|
|
|
-- name: UpsertPanelData :one
|
|
INSERT INTO panel_data (media_item_id, page_number, detection_method, panels)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (media_item_id, page_number)
|
|
DO UPDATE SET
|
|
detection_method = EXCLUDED.detection_method,
|
|
panels = EXCLUDED.panels,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: GetReadingSpeed :one
|
|
SELECT * FROM reading_speed
|
|
WHERE user_id = $1 AND media_item_id = $2;
|
|
|
|
-- name: CreateReadingSpeed :one
|
|
INSERT INTO reading_speed (user_id, media_item_id, pages_per_minute, pages_read, total_reading_minutes, last_read_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING *;
|
|
|
|
-- name: UpdateReadingSpeed :one
|
|
UPDATE reading_speed
|
|
SET
|
|
pages_per_minute = $3,
|
|
pages_read = pages_read + $4,
|
|
total_reading_minutes = total_reading_minutes + $5,
|
|
last_read_at = $6,
|
|
updated_at = NOW()
|
|
WHERE user_id = $1 AND media_item_id = $2
|
|
RETURNING *;
|
|
|
|
-- name: GetDictionaryEntry :one
|
|
SELECT * FROM dictionary_cache
|
|
WHERE word = $1;
|
|
|
|
-- name: CreateDictionaryEntry :one
|
|
INSERT INTO dictionary_cache (word, definition, part_of_speech, example, etymology, accessed_at)
|
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
|
RETURNING *;
|
|
|
|
-- name: UpdateDictionaryAccessed :one
|
|
UPDATE dictionary_cache
|
|
SET accessed_at = NOW()
|
|
WHERE word = $1
|
|
RETURNING *;
|
|
|
|
-- name: GetReaderSettings :one
|
|
SELECT setting_value FROM reader_settings
|
|
WHERE user_id = $1 AND setting_key = 'reader_settings';
|
|
|
|
-- name: UpsertReaderSettings :one
|
|
INSERT INTO reader_settings (user_id, setting_key, setting_value)
|
|
VALUES ($1, 'reader_settings', $2)
|
|
ON CONFLICT (user_id, setting_key)
|
|
DO UPDATE SET
|
|
setting_value = EXCLUDED.setting_value,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: GetMediaBookmarks :many
|
|
SELECT * FROM media_bookmarks
|
|
WHERE media_item_id = $1 AND user_id = $2
|
|
ORDER BY created_at DESC;
|
|
|
|
-- name: CreateMediaBookmark :one
|
|
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING *;
|
|
|
|
-- name: DeleteMediaBookmark :exec
|
|
DELETE FROM media_bookmarks
|
|
WHERE id = $1;
|
|
|
|
-- name: UpdateMediaBookmark :one
|
|
UPDATE media_bookmarks
|
|
SET
|
|
title = $2,
|
|
notes = $3,
|
|
position = $4,
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND user_id = $5
|
|
RETURNING *;
|
|
```
|
|
|
|
**After adding the queries, regenerate:**
|
|
```bash
|
|
cd /home/nymusicman/Code/bookhoard/database
|
|
sqlc generate
|
|
```
|
|
|
|
### 0.3 Frontend Dependencies
|
|
|
|
**File:** `package.json` (modify existing)
|
|
|
|
Add these dependencies to the `dependencies` section:
|
|
|
|
```json
|
|
{
|
|
"dependencies": {
|
|
"jszip": "^3.10.1",
|
|
"pdfjs-dist": "^3.11.174"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Then install:**
|
|
```bash
|
|
npm install
|
|
```
|
|
|
|
### 0.4 Directory Structure
|
|
|
|
**Create the required directory structure:**
|
|
|
|
```bash
|
|
# Frontend reader directories
|
|
mkdir -p web/src/reader/ebook
|
|
mkdir -p web/src/reader/comic
|
|
mkdir -p web/src/reader/manga
|
|
mkdir -p web/src/reader/pdf
|
|
|
|
# Fonts directory
|
|
mkdir -p web/static/fonts
|
|
|
|
# Bruno API test directory
|
|
mkdir -p bruno/reader
|
|
```
|
|
|
|
### 0.5 Pre-Implementation Checklist
|
|
|
|
Before starting Phase 1, verify all items are complete:
|
|
|
|
- [ ] **Database**: Added SQL from **Section 2.1** to `database/schema/schema.sql`
|
|
- [ ] **Database**: Added ALTER TABLE from **Section 2.2** to `database/schema/schema.sql`
|
|
- [ ] **Database**: Added queries to `internal/database/queries/queries.sql` (from this section)
|
|
- [ ] **Database**: Ran `sqlc generate` to regenerate Go code
|
|
- [ ] **Database**: Updated local database (recreated or migrated)
|
|
- [ ] **Frontend**: Added `jszip` and `pdfjs-dist` to `package.json`
|
|
- [ ] **Frontend**: Ran `npm install` to install dependencies
|
|
- [ ] **Structure**: Created all required directories (mkdir commands above)
|
|
- [ ] **Verification**: Can run `go build ./...` without errors
|
|
- [ ] **Verification**: Database tables exist (check with `\dt` in psql)
|
|
|
|
### 0.6 Verification Commands
|
|
|
|
**Verify database tables exist:**
|
|
```bash
|
|
podman exec bookhoard_db psql -U postgres -d bookhoard -c "\dt panel_data reading_speed dictionary_cache reader_settings media_bookmarks"
|
|
```
|
|
|
|
**Expected output should show 5 tables.**
|
|
|
|
**Verify queries were generated:**
|
|
```bash
|
|
grep -c "GetPanelData\|UpsertPanelData\|GetReadingSpeed\|GetDictionaryEntry\|GetReaderSettings\|GetMediaBookmarks" /home/nymusicman/Code/bookhoard/internal/database/queries.sql.go
|
|
# Should return count > 0 (at least 10-12 matches)
|
|
```
|
|
|
|
**Verify frontend dependencies:**
|
|
```bash
|
|
npm list jszip pdfjs-dist
|
|
# Should show versions, not "(empty)"
|
|
```
|
|
|
|
**Expected output:**
|
|
```
|
|
bookhoard@1.0.0 /home/nymusicman/Code/bookhoard
|
|
├── jszip@3.10.1
|
|
└── pdfjs-dist@3.11.174
|
|
```
|
|
|
|
**Verify Go compilation:**
|
|
```bash
|
|
go build ./...
|
|
# Should complete without errors
|
|
```
|
|
|
|
**Verify directories exist:**
|
|
```bash
|
|
ls -la web/src/reader/ | grep -E "ebook|comic|manga|pdf"
|
|
# Should show 4 directories
|
|
```
|
|
|
|
### 0.7 Troubleshooting
|
|
|
|
**Problem:** `sqlc generate` fails with "undefined type"
|
|
**Solution:** Make sure you copied the SQL from Section 2.1 EXACTLY as written
|
|
|
|
**Problem:** Database tables don't show up after recreation
|
|
**Solution:** Check that SQL was added BEFORE the index section in schema.sql
|
|
|
|
**Problem:** `npm install` fails
|
|
**Solution:** Try `npm cache clean --force` then `npm install` again
|
|
|
|
**Problem:** `go build` fails with "undefined: GetPanelData"
|
|
**Solution:** Make sure you ran `sqlc generate` after adding queries
|
|
|
|
**Problem:** psql shows "column does not exist" for chapter_metadata
|
|
**Solution:** Make sure you ran the ALTER TABLE command from Section 2.2
|
|
|
|
---
|
|
|
|
**File:** `internal/database/queries/queries.sql` (modify existing)
|
|
|
|
**Add these queries to the queries.sql file:**
|
|
|
|
```sql
|
|
-- name: GetPanelData :one
|
|
SELECT * FROM panel_data
|
|
WHERE media_item_id = $1 AND page_number = $2;
|
|
|
|
-- name: UpsertPanelData :one
|
|
INSERT INTO panel_data (media_item_id, page_number, detection_method, panels)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (media_item_id, page_number)
|
|
DO UPDATE SET
|
|
detection_method = EXCLUDED.detection_method,
|
|
panels = EXCLUDED.panels,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: GetReadingSpeed :one
|
|
SELECT * FROM reading_speed
|
|
WHERE user_id = $1 AND media_item_id = $2;
|
|
|
|
-- name: CreateReadingSpeed :one
|
|
INSERT INTO reading_speed (user_id, media_item_id, pages_per_minute, pages_read, total_reading_minutes, last_read_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING *;
|
|
|
|
-- name: UpdateReadingSpeed :one
|
|
UPDATE reading_speed
|
|
SET
|
|
pages_per_minute = $3,
|
|
pages_read = pages_read + $4,
|
|
total_reading_minutes = total_reading_minutes + $5,
|
|
last_read_at = $6,
|
|
updated_at = NOW()
|
|
WHERE user_id = $1 AND media_item_id = $2
|
|
RETURNING *;
|
|
|
|
-- name: GetDictionaryEntry :one
|
|
SELECT * FROM dictionary_cache
|
|
WHERE word = $1;
|
|
|
|
-- name: CreateDictionaryEntry :one
|
|
INSERT INTO dictionary_cache (word, definition, part_of_speech, example, etymology, accessed_at)
|
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
|
RETURNING *;
|
|
|
|
-- name: UpdateDictionaryAccessed :one
|
|
UPDATE dictionary_cache
|
|
SET accessed_at = NOW()
|
|
WHERE word = $1
|
|
RETURNING *;
|
|
|
|
-- name: GetReaderSettings :one
|
|
SELECT setting_value FROM reader_settings
|
|
WHERE user_id = $1 AND setting_key = 'reader_settings';
|
|
|
|
-- name: UpsertReaderSettings :one
|
|
INSERT INTO reader_settings (user_id, setting_key, setting_value)
|
|
VALUES ($1, 'reader_settings', $2)
|
|
ON CONFLICT (user_id, setting_key)
|
|
DO UPDATE SET
|
|
setting_value = EXCLUDED.setting_value,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: GetMediaBookmarks :many
|
|
SELECT * FROM media_bookmarks
|
|
WHERE media_item_id = $1 AND user_id = $2
|
|
ORDER BY created_at DESC;
|
|
|
|
-- name: CreateMediaBookmark :one
|
|
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING *;
|
|
|
|
-- name: DeleteMediaBookmark :exec
|
|
DELETE FROM media_bookmarks
|
|
WHERE id = $1;
|
|
```
|
|
|
|
**Regenerate queries after adding:**
|
|
```bash
|
|
cd /home/nymusicman/Code/bookhoard/database
|
|
sqlc generate
|
|
```
|
|
|
|
### 0.3 Frontend Dependencies
|
|
|
|
**File:** `package.json` (modify existing)
|
|
|
|
**Add these dependencies to the dependencies section:**
|
|
|
|
```json
|
|
{
|
|
"dependencies": {
|
|
"jszip": "^3.10.1",
|
|
"pdfjs-dist": "^3.11.174"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Then install:**
|
|
```bash
|
|
npm install
|
|
```
|
|
|
|
### 0.4 Directory Structure
|
|
|
|
**Create the required directory structure:**
|
|
|
|
```bash
|
|
# Frontend reader directories
|
|
mkdir -p web/src/reader/ebook
|
|
mkdir -p web/src/reader/comic
|
|
mkdir -p web/src/reader/manga
|
|
mkdir -p web/src/reader/pdf
|
|
|
|
# Fonts directory
|
|
mkdir -p web/static/fonts
|
|
|
|
# Bruno API test directory
|
|
mkdir -p bruno/reader
|
|
```
|
|
|
|
### 0.5 Pre-Implementation Checklist
|
|
|
|
Before starting Phase 1, verify all items are complete:
|
|
|
|
- [ ] **Database**: Added 5 new tables to `database/schema/schema.sql`
|
|
- [ ] **Database**: Added SQL queries to `internal/database/queries/queries.sql`
|
|
- [ ] **Database**: Ran `sqlc generate` to regenerate Go code
|
|
- [ ] **Database**: Updated local database (recreated or migrated)
|
|
- [ ] **Frontend**: Added `jszip` and `pdfjs-dist` to `package.json`
|
|
- [ ] **Frontend**: Ran `npm install` to install dependencies
|
|
- [ ] **Structure**: Created all required directories
|
|
- [ ] **Verification**: Can run `go build ./...` without errors
|
|
- [ ] **Verification**: Database tables exist (check with `\dt` in psql)
|
|
|
|
### 0.6 Verification Commands
|
|
|
|
**Verify database tables exist:**
|
|
```bash
|
|
podman exec bookhoard_db psql -U postgres -d bookhoard -c "\dt panel_data reading_speed dictionary_cache reader_settings media_bookmarks"
|
|
```
|
|
|
|
**Verify queries were generated:**
|
|
```bash
|
|
grep -c "GetPanelData\|UpsertPanelData\|GetReadingSpeed" /home/nymusicman/Code/bookhoard/internal/database/queries.sql.go
|
|
# Should return count > 0
|
|
```
|
|
|
|
**Verify frontend dependencies:**
|
|
```bash
|
|
npm list jszip pdfjs-dist
|
|
# Should show versions, not "(empty)"
|
|
```
|
|
|
|
**Verify Go compilation:**
|
|
```bash
|
|
go build ./...
|
|
# Should complete without errors
|
|
```
|
|
|
|
---
|
|
|
|
## 1. Architecture
|
|
|
|
### 1.1 Universal Reader with Pluggable Parsers
|
|
|
|
**Architectural Decision: Single Reader + Parser Pipeline**
|
|
|
|
Instead of separate readers for each format, we use **one universal reader** with **pluggable parsers** that convert all formats to a **Common Intermediate Format (CIF)**.
|
|
|
|
```
|
|
┌──────────────────────────────────────────────────────────┐
|
|
│ Universal Ebook Reader (Single) │
|
|
│ - HTML Renderer (shared) │
|
|
│ - Typography Engine (shared) │
|
|
│ - Progress Tracker (shared) │
|
|
│ - Annotation Manager (shared) │
|
|
│ - Navigation Controls (shared) │
|
|
└──────────────────────────────────────────────────────────┘
|
|
↓
|
|
┌──────────────────────────────────────────────────────────┐
|
|
│ Common Intermediate Format (CIF) │
|
|
│ - Standardized HTML structure │
|
|
│ - Universal metadata schema │
|
|
│ - Unified navigation (TOC) │
|
|
│ - Consistent resource loading │
|
|
└──────────────────────────────────────────────────────────┘
|
|
↓
|
|
┌──────────────────────────────────────────────────────────┐
|
|
│ Parser Manager (Router) │
|
|
│ Detects format → Routes to appropriate parser │
|
|
└──────────────────────────────────────────────────────────┘
|
|
↓
|
|
┌─────────┬─────────┬──────────┬──────────┐
|
|
│ EPUB │ FB2 │ TXT │ HTML │ ← Client-side
|
|
│ Parser │ Parser │ Parser │ Parser │ (TypeScript)
|
|
└─────────┴─────────┴──────────┴──────────┘
|
|
|
|
┌─────────┬─────────┬──────────┬──────────┐
|
|
│ MOBI │ AZW3 │ DOCX │ RTF │ ← Server-side
|
|
│ Parser │ Parser │ Parser │ Parser │ (Go backend)
|
|
└─────────┴─────────┴──────────┴──────────┘
|
|
|
|
PDF and Comics use dedicated readers (not CIF pipeline):
|
|
- PDFReader (pdf.js) - Fixed-layout documents
|
|
- ComicReader (canvas) - Image archives
|
|
- MangaReader (extends Comic) - RTL/vertical modes
|
|
```
|
|
|
|
**Why This Approach?**
|
|
|
|
1. **Code Reuse**: One reader implementation for all reflowable ebooks
|
|
2. **Consistency**: All formats have identical UI/UX
|
|
3. **Maintainability**: Fix bug once, applies to all formats
|
|
4. **Extensibility**: Add new format by implementing parser interface
|
|
5. **Performance**: Client-side for simple formats, server-side for complex
|
|
|
|
### 1.2 Component Structure
|
|
|
|
```
|
|
Reader Infrastructure (Shared)
|
|
├── reader-shell.ts - UI shell, chrome control, routing
|
|
├── panel-dock-system.ts - Modular dockable panel system
|
|
│ ├── panel-container.ts - Base panel container with dock logic
|
|
│ │ - Lock toggle: prevents accidental drag/move
|
|
│ │ - Snap-back: returns to last valid position if dropped in invalid area
|
|
│ │ - Dock zones: left edge, right edge, valid drop targets
|
|
│ │ - Drag handlers: mouse/touch drag to reposition
|
|
│ │ - Drop zone detection: validates drop position
|
|
│ │ - Persist state: saves to settings on move/lock/collapse
|
|
│ ├── window-shade.ts - Vertical collapse to title bar
|
|
│ │ - Animated collapse/expand
|
|
│ │ - State persistence
|
|
│ └── panel-state.ts - Per-user layout persistence (includes lock state)
|
|
├── progress-tracker.ts - Integration with reading_progress table
|
|
├── annotation-manager.ts - Integration with notes/highlights tables
|
|
├── websocket-sync.ts - Reuse existing sync system
|
|
├── settings-manager.ts - Per-user preferences (DB + localStorage)
|
|
├── bookmark-manager.ts - Integration with existing bookmarks
|
|
└── chapter-detector.ts - Chapter detection for all media types
|
|
|
|
Dockable Panel Components (Reusable)
|
|
├── toc-panel.ts - Table of Contents (dockable left/right, window-shade)
|
|
├── settings-panel.ts - Reader settings (dockable left/right, window-shade)
|
|
├── navigator-panel.ts - Page navigator with viewport box (dockable left/right, window-shade)
|
|
└── bookmarks-panel.ts - User bookmarks (dockable left/right, window-shade, default bottom-right)
|
|
|
|
Universal Ebook Reader (Reflowable Formats)
|
|
├── html-renderer.ts - Browser-native HTML rendering (shared)
|
|
├── typography-engine.ts - Font rendering, theme integration (shared)
|
|
├── cfi-navigator.ts - Universal position navigation
|
|
├── dictionary-popup.ts - Offline dictionary lookup
|
|
|
|
Parser Pipeline
|
|
├── parser-manager.ts - Routes format → appropriate parser
|
|
├── cif-types.ts - Common Intermediate Format types
|
|
│
|
|
├── Client-Side Parsers (TypeScript)
|
|
│ ├── epub-parser.ts - EPUB 2/3 parsing (ZIP + XML)
|
|
│ ├── fb2-parser.ts - FictionBook 2 parsing (XML)
|
|
│ ├── txt-parser.ts - Plain text wrapper
|
|
│ └── html-parser.ts - Standalone HTML files
|
|
│
|
|
└── Server-Side Parsers (Go backend)
|
|
├── mobi-parser.go - MOBI parsing
|
|
├── azw3-parser.go - AZW3/KF8 parsing
|
|
├── docx-parser.go - Word document parsing
|
|
└── rtf-parser.go - Rich Text Format parsing
|
|
|
|
PDF Reader (Fixed Layout)
|
|
├── pdfjs-wrapper.ts - Mozilla pdf.js integration
|
|
├── text-layer-renderer.ts - Text layer overlay for selection
|
|
├── annotation-layer.ts - Highlight/note rendering
|
|
├── pdf-navigation.ts - Page navigation, zoom, fit modes
|
|
├── pdf-search.ts - Full-text search within PDF
|
|
├── page-cache.ts - 5-page ahead cache
|
|
├── text-selection.ts - Text selection and highlight creation
|
|
├── pdf-outline.ts - TOC navigation
|
|
├── pdf-bookmarks.ts - Custom bookmarks
|
|
├── pdf-clipboard.ts - Copy to clipboard
|
|
├── pdf-links.ts - Internal link handling
|
|
├── pdf-dual-page.ts - Dual page spread view
|
|
├── pdf-rotation.ts - Rotated page support
|
|
└── pdf-page-sizes.ts - Variable page size handling
|
|
|
|
Comic Reader (Image Archives) - Uses shared navigator-panel.ts for page navigation
|
|
├── image-archive-parser.ts - CBZ/CBR parsing
|
|
├── canvas-renderer.ts - Canvas rendering with lazy loading
|
|
├── panel-detector.ts - Grid-based + ML + manual override
|
|
├── panel-navigator.ts - Panel zoom with smooth animations (when detection ON)
|
|
└── page-cache.ts - 5-page ahead cache
|
|
|
|
Manga Reader (extends Comic)
|
|
├── rtl-navigator.ts - Right-to-left navigation
|
|
├── vertical-scroll-mode.ts - Webtoon-style vertical scroll
|
|
└── panel-detector.ts - Manga-aware panel detection
|
|
```
|
|
|
|
### 1.3 Theming Strategy (Hybrid Approach)
|
|
|
|
**Design Decision:**
|
|
|
|
Bookhoard Reader uses a **hybrid theming approach** to balance user personalization with reading best practices:
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ UI Chrome (Bars, Panels, Settings) │
|
|
│ ✅ All 11 Bookhoard themes available │
|
|
│ - tokyo-night, dracula, nord, etc. │
|
|
│ - Maintains consistency with rest of app │
|
|
└─────────────────────────────────────────────────────────┘
|
|
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ Ebook Text Content │
|
|
│ ✅ 5 reading-optimized themes only │
|
|
│ - Light (standard) │
|
|
│ - Sepia (warm, easier on eyes) │
|
|
│ - Dark (reduced eye strain) │
|
|
│ - Night (reduced blue light for better sleep) │
|
|
│ - High Contrast (accessibility) │
|
|
└─────────────────────────────────────────────────────────┘
|
|
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ PDF Documents │
|
|
│ ✅ 5 reading-optimized themes only │
|
|
│ - Light, Sepia, Dark, Night, High Contrast │
|
|
│ - PDF.js supports custom CSS for text layer │
|
|
│ - Maintains readability for long documents │
|
|
└─────────────────────────────────────────────────────────┘
|
|
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ Comic/Manga Images │
|
|
│ ✅ All 11 Bookhoard themes available │
|
|
│ - Visual content works well with any theme │
|
|
│ - No eye fatigue concerns with images │
|
|
└─────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
**Why This Approach?**
|
|
|
|
1. **Reading Science**: Long-form reading (300+ pages) requires eye-comfort optimization
|
|
2. **User Expectations**: Kindle, Kobo, Apple Books offer 3-5 reading themes
|
|
3. **Accessibility**: Reading-optimized themes help users with visual impairments
|
|
4. **Best Practices**: Unusual colors (purple text) cause eye fatigue over long sessions
|
|
5. **Flexibility**: Still have full theming for UI and visual content
|
|
|
|
**Popular Ebook Reader Comparison:**
|
|
|
|
| Reader | Reading Themes | Color Options? |
|
|
|-------------|----------------|----------------|
|
|
| Kindle | 4 | No |
|
|
| Kobo | 4 | No (green for night) |
|
|
| Apple Books | 5 | No |
|
|
| **Bookhoard** | **5 (ebooks)** | **Yes (11 themes for UI/comics)** |
|
|
|
|
**Implementation:**
|
|
|
|
- `chrome_theme`: Applied to reader shell, navigation bars, settings panels
|
|
- `reading_theme`: Applied to ebook text content only (5 options)
|
|
- Comics/manga: Use `chrome_theme` (all 11 themes work well)
|
|
|
|
### 1.4 Data Flow
|
|
|
|
```
|
|
User opens reader
|
|
↓
|
|
Backend: GET /readers/:mediaItemId (SSR skeleton page)
|
|
↓
|
|
Verify access, fetch initial metadata, progress, bookmarks
|
|
↓
|
|
SSR render: templates/reader.templ (skeleton shell with data attributes)
|
|
- Passes: metadata, progress, bookmarks for initial display
|
|
- Renders: chrome UI, panel containers (empty), data attributes
|
|
- Does NOT render: actual content pages (loaded by JS)
|
|
↓
|
|
Frontend TypeScript: reader-shell.ts initializes
|
|
- Reads: data-media-item-id, data-media-type, data-total-pages
|
|
- Detects: media type (ebook/pdf/comic/manga)
|
|
- Loads: appropriate reader (EbookReader/PDFReader/ComicReader/MangaReader)
|
|
↓
|
|
Reader fetches content dynamically:
|
|
- Ebooks: /readers/:id/pages/:n (HTML chapters)
|
|
- Comics: /readers/:id/pages/:n (images)
|
|
- PDFs: /readers/:id/pages/:n (text layer + images)
|
|
↓
|
|
User interacts (turn page, highlight, bookmark)
|
|
- All UI logic handled by TypeScript
|
|
- Progress updates sent via WebSocket
|
|
↓
|
|
Real-time sync via WebSocket (reuse existing system)
|
|
```
|
|
|
|
### 1.5 SSR vs Client-Side Responsibilities
|
|
|
|
**IMPORTANT:** The reader uses a hybrid approach - SSR provides the shell, TypeScript handles all interactivity.
|
|
|
|
**SSR (Go template) responsibilities:**
|
|
- ✅ Render HTML skeleton/structure
|
|
- ✅ Embed initial metadata (title, author, cover)
|
|
- ✅ Embed initial progress (current page, percentage)
|
|
- ✅ Embed initial bookmarks list
|
|
- ✅ Render chrome UI (top/bottom bars with basic info)
|
|
- ✅ Render panel containers (empty, populated by JS)
|
|
- ✅ Set data attributes for Alpine.js initialization
|
|
- ❌ Does NOT render actual content pages
|
|
- ❌ Does NOT handle reader interactions
|
|
|
|
**Client-Side (TypeScript) responsibilities:**
|
|
- ✅ Read data attributes from DOM
|
|
- ✅ Detect media type and initialize appropriate reader
|
|
- ✅ Fetch and render content pages dynamically
|
|
- ✅ Handle all user interactions (page turns, zoom, pan)
|
|
- ✅ Manage panel state (show/hide, dock, collapse)
|
|
- ✅ Sync progress via WebSocket
|
|
- ✅ Handle highlights, annotations, bookmarks CRUD
|
|
- ✅ Implement reading features (dictionary, search, TOC navigation)
|
|
|
|
**Example: What SSR provides:**
|
|
```html
|
|
<body data-media-item-id="123" data-media-type="ebook" data-total-pages="300">
|
|
<div id="reader-chrome">
|
|
<h1>Book Title</h1> <!-- From metadata -->
|
|
<span>Page 45/300</span> <!-- From progress -->
|
|
</div>
|
|
<main id="reader-content">
|
|
<!-- Empty - JavaScript will load pages here -->
|
|
</main>
|
|
<div id="toc-panel" class="panel-container">
|
|
<!-- Empty shell - JavaScript populates TOC -->
|
|
</div>
|
|
</body>
|
|
```
|
|
|
|
**Example: What TypeScript does:**
|
|
```typescript
|
|
// reader-shell.ts
|
|
function initReader() {
|
|
const mediaItemId = document.body.dataset.mediaItemId;
|
|
const mediaType = document.body.dataset.mediaType;
|
|
|
|
// Fetch and render first page
|
|
loadPage(mediaItemId, 1, mediaType);
|
|
|
|
// Initialize panel interactions
|
|
initPanelSystem();
|
|
|
|
// Setup WebSocket sync
|
|
initWebSocketSync(mediaItemId);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 2. Database Schema Changes
|
|
|
|
### 2.1 New Tables
|
|
|
|
```sql
|
|
-- Panel detection data
|
|
CREATE TABLE IF NOT EXISTS panel_data (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
page_number INTEGER NOT NULL,
|
|
detection_method VARCHAR(20) NOT NULL, -- 'grid', 'ml', 'manual'
|
|
panels JSONB NOT NULL,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
UNIQUE(media_item_id, page_number)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_panel_data_media_item ON panel_data(media_item_id);
|
|
|
|
-- Reading speed tracking
|
|
CREATE TABLE IF NOT EXISTS reading_speed (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
|
words_per_minute DECIMAL(6,2),
|
|
pages_per_minute DECIMAL(6,2),
|
|
pages_read INTEGER DEFAULT 0,
|
|
total_reading_minutes DECIMAL(8,2) DEFAULT 0,
|
|
last_read_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
UNIQUE(user_id, media_item_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_reading_speed_user ON reading_speed(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_id);
|
|
|
|
-- Dictionary cache (for offline use)
|
|
CREATE TABLE IF NOT EXISTS dictionary_cache (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
word VARCHAR(100) NOT NULL UNIQUE,
|
|
definition TEXT NOT NULL,
|
|
part_of_speech VARCHAR(20),
|
|
example TEXT,
|
|
etymology TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
accessed_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_dictionary_word ON dictionary_cache(word);
|
|
|
|
-- Reader settings (per-user preferences)
|
|
CREATE TABLE IF NOT EXISTS reader_settings (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
setting_key VARCHAR(50) NOT NULL,
|
|
setting_value JSONB NOT NULL,
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
UNIQUE(user_id, setting_key)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_reader_settings_user ON reader_settings(user_id);
|
|
|
|
-- PDF bookmarks (custom user bookmarks)
|
|
CREATE TABLE IF NOT EXISTS 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,
|
|
chapter_number INTEGER,
|
|
cfi_position VARCHAR(255), -- For ebooks: EPUB CFI position
|
|
title VARCHAR(255) NOT NULL,
|
|
position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
UNIQUE(media_item_id, user_id, COALESCE(page_number, 0), COALESCE(chapter_number, 0))
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id);
|
|
```
|
|
|
|
### 2.2 Alter Existing Tables
|
|
|
|
```sql
|
|
-- Add chapter metadata to media_items
|
|
ALTER TABLE media_items
|
|
ADD COLUMN chapter_metadata JSONB;
|
|
|
|
-- Example structure:
|
|
-- {
|
|
-- "chapters": [
|
|
-- {"id": "chap1", "title": "Chapter 1", "start_page": 1, "page_count": 20},
|
|
-- {"id": "chap2", "title": "Chapter 2", "start_page": 21, "page_count": 25}
|
|
-- ]
|
|
-- }
|
|
|
|
-- Note: reading_progress table already exists with epubcfi, page, percentage fields
|
|
-- Note: notes and highlights tables already exist
|
|
-- Note: bookmarks table already exists
|
|
```
|
|
|
|
### 2.3 Schema.sql Implementation
|
|
|
|
**File:** `database/schema/schema.sql`
|
|
|
|
Add the above tables to the schema file. Follow existing patterns:
|
|
- Use `gen_random_uuid()` for UUID defaults
|
|
- Use `TIMESTAMPTZ DEFAULT NOW()` for timestamps
|
|
- Add appropriate indexes for foreign keys
|
|
- Use `ON DELETE CASCADE` for referential integrity
|
|
|
|
---
|
|
|
|
## 2.4 Required Code Implementation (Database & Go Backend)
|
|
|
|
### Database Tables (Already Exist)
|
|
|
|
The following tables already exist in `database/schema/schema.sql`:
|
|
- `panel_data` - Panel detection for comics/manga
|
|
- `reader_settings` - **Stores panel layout configuration** (including dock positions, lock state, collapsed state, width)
|
|
- `media_bookmarks` - User bookmarks
|
|
- `reading_speed` - Reading statistics
|
|
- `dictionary_cache` - Offline dictionary
|
|
|
|
**Panel layout storage**: All panel dock system state (side, visible, collapsed, width_px, order, locked, last_valid_side) is stored as JSON in `reader_settings.setting_value` under the `panel_layout` key.
|
|
|
|
### Database Schema Issues to Fix
|
|
|
|
The following database schema issues need to be resolved before implementation:
|
|
|
|
1. **media_items.chapter_metadata**: Add to schema as `JSONB` column (see Section 2.2)
|
|
|
|
2. **reader_settings table query**: The generated query `UpsertReaderSettings` expects:
|
|
- Parameters: `UserID` (UUID), `SettingValue` ([]byte)
|
|
- The query uses `setting_key = 'reader_settings'` hardcoded
|
|
- Code using `SettingKey` field will fail - remove that field from params
|
|
|
|
3. **reading_speed table fields**: The generated model uses different types:
|
|
- `pages_per_minute` is `pgtype.Float8`, not `pgtype.Numeric`
|
|
- `pages_read` is `pgtype.Int4`, not plain `int32`
|
|
- Fix type conversions in service code
|
|
|
|
4. **panel_data table**: The generated model `database.PanelData` is a struct, not a type with `Valid/Bytes` fields
|
|
- Code using `cached.Valid` and `cached.Bytes` will fail
|
|
- Need to query directly and handle JSON unmarshaling differently
|
|
|
|
### Database Queries to Add
|
|
|
|
Add these queries to `internal/database/queries/queries.sql`:
|
|
|
|
```sql
|
|
-- name: UpdateMediaItemChapterMetadata :one
|
|
UPDATE media_items
|
|
SET chapter_metadata = $2, updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: GetReadingSpeedByUser :one
|
|
SELECT * FROM reading_speed
|
|
WHERE user_id = $1 AND media_item_id = $2;
|
|
|
|
-- name: UpsertReadingSpeed :one
|
|
INSERT INTO reading_speed (user_id, media_item_id, words_per_minute, pages_per_minute, pages_read, total_reading_minutes, last_read_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
ON CONFLICT (user_id, media_item_id)
|
|
DO UPDATE SET
|
|
words_per_minute = COALESCE($3, reading_speed.words_per_minute),
|
|
pages_per_minute = COALESCE($4, reading_speed.pages_per_minute),
|
|
pages_read = reading_speed.pages_read + COALESCE($5, 0),
|
|
total_reading_minutes = reading_speed.total_reading_minutes + COALESCE($6, 0),
|
|
last_read_at = $7,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
```
|
|
|
|
### Go Service Implementation Issues
|
|
|
|
The following issues in `internal/services/reader.go` need fixing:
|
|
|
|
1. **Line 71-76**: Chapter metadata is `[]byte`, not `pgtype.JSONB`
|
|
```go
|
|
// Change from:
|
|
if item.ChapterMetadata.Valid { ... item.ChapterMetadata.Bytes ... }
|
|
// To:
|
|
if len(item.ChapterMetadata) > 0 { ... item.ChapterMetadata ... }
|
|
```
|
|
|
|
2. **Line 126**: PageCount is `pgtype.Int4`, use `.Int32` not `.Int64`
|
|
```go
|
|
// Change from:
|
|
pageCount := int(item.PageCount.Int64)
|
|
// To:
|
|
pageCount := int(item.PageCount.Int32)
|
|
```
|
|
|
|
3. **Lines 172-174**: PanelData query returns `database.PanelData` struct, handle differently
|
|
|
|
4. **Lines 283-285**: Fix pgtype conversions for reading_speed
|
|
```go
|
|
// Use proper pgtype types:
|
|
PagesPerMinute: pgtype.Float8{Float64: pagesPerMinute, Valid: true},
|
|
PagesRead: pgtype.Int4{Int32: int32(pagesRead), Valid: true},
|
|
TotalReadingMinutes: pgtype.Float8{Float64: minutes, Valid: true},
|
|
```
|
|
|
|
5. **Lines 353, 386-387**: Fix reader settings access
|
|
```go
|
|
// SettingValue is []byte, use directly
|
|
if len(settings.SettingValue) > 0 { ... }
|
|
// UpsertReaderSettingsParams doesn't have SettingKey
|
|
// Use: UserID, SettingValue only
|
|
```
|
|
|
|
### Router Implementation
|
|
|
|
The router at `internal/router/reader.go` has a placeholder SSR handler that needs:
|
|
1. Fetch metadata/progress/bookmarks (inline or via handler)
|
|
2. Import "bookhoard/templates"
|
|
3. Call `templates.Reader()` with proper data
|
|
|
|
Note: This creates import cycle if handler imports templates - follow frontend.go pattern where router handles template rendering directly.
|
|
|
|
---
|
|
|
|
## 3. API Endpoints
|
|
|
|
### 3.1 Reader Routes
|
|
|
|
**IMPORTANT:** Reader routes are split into two locations following Bookhoard's architecture:
|
|
- **SSR page** (HTML): Registered in `internal/router/frontend.go`
|
|
- **API endpoints** (JSON): Registered in `internal/router/reader.go`
|
|
|
|
**File:** `internal/router/reader.go` (new file)
|
|
|
|
```go
|
|
package router
|
|
|
|
import (
|
|
"bookhoard/internal/handlers"
|
|
"bookhoard/internal/services"
|
|
)
|
|
|
|
func registerReaderRoutes(cfg *Config) {
|
|
e := cfg.Echo
|
|
|
|
// Create reader service and handler
|
|
readerService := services.NewReaderService(cfg.Queries, cfg.Worker)
|
|
cfg.ReaderHandler = handlers.NewReaderHandler(
|
|
cfg.Queries,
|
|
services.NewLibraryService(cfg.Queries), // Create inline, not from cfg
|
|
readerService,
|
|
cfg.Worker,
|
|
)
|
|
|
|
jwtMiddleware := createJWTMiddleware(cfg)
|
|
|
|
// API routes only (JSON responses)
|
|
// Note: SSR reader page is registered in frontend.go under /readers/:mediaItemId
|
|
reader := e.Group("/readers", jwtMiddleware)
|
|
|
|
// Content serving (lazy-loaded pages)
|
|
reader.GET("/:mediaItemId/pages/:pageNumber", cfg.ReaderHandler.GetPage)
|
|
|
|
// Chapter metadata
|
|
reader.GET("/:mediaItemId/chapters", cfg.ReaderHandler.GetChapters)
|
|
|
|
// Panel data (comics/manga)
|
|
reader.GET("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.GetPanels)
|
|
reader.PUT("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.UpdatePanels)
|
|
|
|
// PDF outline/TOC
|
|
reader.GET("/:mediaItemId/outline", cfg.ReaderHandler.GetPDFOutline)
|
|
|
|
// PDF thumbnails (for mini-map)
|
|
reader.GET("/:mediaItemId/thumbnails/:pageNumber", cfg.ReaderHandler.GetPDFThumbnail)
|
|
|
|
// Reading speed
|
|
reader.GET("/:mediaItemId/reading-speed", cfg.ReaderHandler.GetReadingSpeed)
|
|
reader.POST("/:mediaItemId/reading-speed", cfg.ReaderHandler.UpdateReadingSpeed)
|
|
|
|
// Dictionary lookup
|
|
reader.GET("/dictionary/:word", cfg.ReaderHandler.LookupWord)
|
|
|
|
// Reader settings
|
|
reader.GET("/settings", cfg.ReaderHandler.GetSettings)
|
|
reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings)
|
|
|
|
// **PHASE 1 NOTE:** Do NOT register parse route in Phase 1
|
|
// **PHASE 2.5 TASK:** Uncomment below when implementing server-side parsers
|
|
// // Parse endpoint for server-side formats (MOBI, AZW3, DOCX, RTF)
|
|
// reader.POST("/:mediaItemId/parse", cfg.ReaderHandler.ParseEbook)
|
|
}
|
|
```
|
|
|
|
**File:** `internal/router/frontend.go` (modify existing)
|
|
|
|
Add the SSR reader page route to the `frontendProtected` group:
|
|
|
|
```go
|
|
// In registerFrontendRoutes function, after other frontendProtected routes:
|
|
|
|
// Reader page (SSR)
|
|
frontendProtected.GET("/readers/:mediaItemId", func(c *echo.Context) error {
|
|
// Get user
|
|
user, err := getTemplateUserWithTheme(c, cfg)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
|
}
|
|
|
|
// Parse mediaItemID
|
|
mediaItemID := c.Param("mediaItemId")
|
|
parsedUUID, err := uuid.Parse(mediaItemID)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Invalid media item ID", "invalid_id")
|
|
}
|
|
|
|
// Fetch media item
|
|
mediaItem, err := cfg.Queries.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return renderErrorPage(c, "Media item not found", "not_found")
|
|
}
|
|
return renderErrorPage(c, "Failed to fetch media item", "db_error")
|
|
}
|
|
|
|
// Check library access (using GetUserVisibleLibraries pattern)
|
|
userUUID, _ := uuid.Parse(user.ID)
|
|
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
|
if err != nil {
|
|
return renderErrorPage(c, "Failed to check access", "access_error")
|
|
}
|
|
|
|
visible := false
|
|
for _, lib := range libraries {
|
|
if lib.ID == mediaItem.LibraryID {
|
|
visible = true
|
|
break
|
|
}
|
|
}
|
|
if !visible {
|
|
return renderErrorPage(c, "Access denied to this library", "access_denied")
|
|
}
|
|
|
|
// Get reading progress
|
|
var progress database.ReadingProgress
|
|
progress, err = cfg.Queries.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
|
|
UserID: uuidToPGType(userUUID),
|
|
})
|
|
if err != nil && err != pgx.ErrNoRows {
|
|
progress = database.ReadingProgress{}
|
|
}
|
|
|
|
// Get bookmarks
|
|
bookmarks, _ := cfg.Queries.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
|
|
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
|
|
UserID: uuidToPGType(userUUID),
|
|
})
|
|
|
|
// Convert to template types (inline, following frontend.go pattern)
|
|
mediaUUID, _ := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
|
|
metadata := templates.ReaderMetadata{
|
|
MediaItemID: mediaUUID.String(),
|
|
Title: mediaItem.Title,
|
|
Author: textToString(mediaItem.Author),
|
|
CoverImagePath: textToString(mediaItem.CoverImagePath),
|
|
LibraryType: mediaItem.FormatGroup,
|
|
MimeType: textToString(mediaItem.MimeType),
|
|
FilePath: mediaItem.FilePath,
|
|
TotalPages: int(mediaItem.PageCount.Int32),
|
|
ChapterCount: int(mediaItem.ChapterCount.Int32),
|
|
}
|
|
|
|
// Progress conversion (inline)
|
|
progressUUID, _ := uuid.FromBytes(progress.ID.Bytes[0:16])
|
|
progressMediaUUID, _ := uuid.FromBytes(progress.MediaItemID.Bytes[0:16])
|
|
progressUserUUID, _ := uuid.FromBytes(progress.UserID.Bytes[0:16])
|
|
templateProgress := templates.ReadingProgress{
|
|
ID: progressUUID.String(),
|
|
MediaItemID: progressMediaUUID.String(),
|
|
UserID: progressUserUUID.String(),
|
|
CurrentPage: int(progress.CurrentPage.Int32),
|
|
TotalPages: int(progress.TotalPages.Int32),
|
|
Percentage: progress.Percentage.Float64,
|
|
EpubCfi: textToString(progress.Epubcfi),
|
|
LastReadAt: progress.LastReadAt.Time,
|
|
}
|
|
|
|
// Bookmarks conversion (inline, with loop)
|
|
templateBookmarks := make([]templates.Bookmark, len(bookmarks))
|
|
for i, b := range bookmarks {
|
|
bookmarkUUID, _ := uuid.FromBytes(b.ID.Bytes[0:16])
|
|
bookmarkMediaUUID, _ := uuid.FromBytes(b.MediaItemID.Bytes[0:16])
|
|
bookmarkUserUUID, _ := uuid.FromBytes(b.UserID.Bytes[0:16])
|
|
|
|
var pageNumber *int
|
|
if b.PageNumber.Valid {
|
|
pn := int(b.PageNumber.Int32)
|
|
pageNumber = &pn
|
|
}
|
|
|
|
var chapterNumber *int
|
|
if b.ChapterNumber.Valid {
|
|
cn := int(b.ChapterNumber.Int32)
|
|
chapterNumber = &cn
|
|
}
|
|
|
|
templateBookmarks[i] = templates.Bookmark{
|
|
ID: bookmarkUUID.String(),
|
|
MediaItemID: bookmarkMediaUUID.String(),
|
|
UserID: bookmarkUserUUID.String(),
|
|
PageNumber: pageNumber,
|
|
ChapterNumber: chapterNumber,
|
|
CfiPosition: textToString(b.CfiPosition),
|
|
Title: b.Title,
|
|
Position: textToString(b.Position),
|
|
Notes: textToString(b.Notes),
|
|
CreatedAt: b.CreatedAt.Time,
|
|
}
|
|
}
|
|
|
|
// Render template
|
|
var buf bytes.Buffer
|
|
err = templates.Reader(user, metadata, templateProgress, templateBookmarks).Render(c.Request().Context(), &buf)
|
|
if err != nil {
|
|
return renderErrorPage(c, "Error rendering reader", "render_error")
|
|
}
|
|
|
|
return c.HTML(http.StatusOK, buf.String())
|
|
})
|
|
```
|
|
|
|
**File:** `internal/router/router.go` (modify existing)
|
|
|
|
Add ReaderHandler to Config struct (around line 38-67):
|
|
|
|
```go
|
|
type Config struct {
|
|
Echo *echo.Echo
|
|
Queries *database.Queries
|
|
Cfg *config.Config
|
|
DBPool interface{}
|
|
AuthHandler *handlers.AuthHandler
|
|
LibraryHandler *handlers.LibraryHandler
|
|
DeviceHandler *handlers.DeviceHandler
|
|
MediaHandler *handlers.MediaHandler
|
|
MatchingHandler *handlers.MatchingHandler
|
|
KOReaderHandler *handlers.KOReaderHandler
|
|
WSHandler *handlers.WSHandler
|
|
ConflictHandler *handlers.ConflictHandler
|
|
AnalyticsHandler *handlers.AnalyticsHandler
|
|
QueueHandler *handlers.QueueHandler
|
|
CollectionHandler *handlers.CollectionHandler
|
|
Worker *services.Worker
|
|
FiltersHandler *handlers.FiltersHandler
|
|
DashboardHandler *handlers.DashboardHandler
|
|
DashboardService *services.DashboardService
|
|
OPDSHandler *handlers.OPDSHandler
|
|
SystemSettingsHandler *handlers.SystemSettingsHandler
|
|
ConnManager *sync.ConnectionManager
|
|
QueueProcessor *sync.SyncQueueProcessor
|
|
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
|
|
LoginTracker *ratelimit.LoginAttemptTracker
|
|
ScannerHandler *handlers.Handler
|
|
JobsHandler *handlers.JobsHandler
|
|
SidecarHandler *handlers.SidecarHandler
|
|
ReaderHandler *handlers.ReaderHandler // ADD THIS LINE
|
|
}
|
|
```
|
|
|
|
Add registerReaderRoutes call in RegisterRoutes function (after line 220):
|
|
|
|
```go
|
|
func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|
// ... existing route registrations ...
|
|
|
|
registerFiltersRoutes(cfg)
|
|
registerOPDSRoutes(cfg)
|
|
registerReaderRoutes(cfg) // ADD THIS LINE - reader routes must be registered before frontend routes
|
|
registerWebSocketRoutes(cfg)
|
|
registerFrontendRoutes(cfg)
|
|
registerDocumentationRoutes(cfg)
|
|
|
|
// ... rest of function ...
|
|
}
|
|
```
|
|
|
|
### 3.2 Handler Implementation
|
|
|
|
**IMPORTANT:** Handlers return JSON only (API endpoints). SSR rendering is handled in `router/frontend.go` to avoid circular dependencies between handlers and templates.
|
|
|
|
**File:** `internal/handlers/reader.go` (new file)
|
|
|
|
Follow existing patterns from `media.go` and `auth.go`:
|
|
- Use `database.Queries` for all DB operations
|
|
- Return JSON responses with consistent structure
|
|
- Handle errors properly (404, 403, 500)
|
|
- **DO NOT import templates package** (causes circular dependency)
|
|
|
|
**ShowReader method is removed** - SSR is handled by router/frontend.go, not handlers
|
|
|
|
**API Handler Structure:**
|
|
|
|
```go
|
|
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/services"
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
type ReaderHandler struct {
|
|
db *database.Queries
|
|
libraryService *services.LibraryService
|
|
readerService *services.ReaderService
|
|
worker *services.Worker
|
|
}
|
|
|
|
func NewReaderHandler(
|
|
db *database.Queries,
|
|
libraryService *services.LibraryService,
|
|
readerService *services.ReaderService,
|
|
worker ...*services.Worker,
|
|
) *ReaderHandler {
|
|
rh := &ReaderHandler{
|
|
db: db,
|
|
libraryService: libraryService,
|
|
readerService: readerService,
|
|
}
|
|
if len(worker) > 0 && worker[0] != nil {
|
|
rh.worker = worker[0]
|
|
}
|
|
return rh
|
|
}
|
|
|
|
// NOTE: No ShowReader method here - SSR is handled by router/frontend.go
|
|
// All methods below return JSON for API endpoints only
|
|
|
|
// GetPage returns a specific page for lazy loading
|
|
func (h *ReaderHandler) GetPage(c echo.Context) error {
|
|
mediaItemID := c.Param("mediaItemId")
|
|
pageNumber := c.Param("pageNumber")
|
|
|
|
parsedUUID, err := uuid.Parse(mediaItemID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
|
|
}
|
|
|
|
page, err := strconv.Atoi(pageNumber)
|
|
if err != nil || page < 1 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"})
|
|
}
|
|
|
|
// Get media item
|
|
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
|
|
}
|
|
|
|
// Check user access
|
|
user := c.Get("user").(database.Users)
|
|
hasAccess, _ := h.libraryService.UserHasLibraryAccess(c.Request().Context(), user.ID, mediaItem.LibraryID)
|
|
if !hasAccess {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied"})
|
|
}
|
|
|
|
// Resolve full file path
|
|
fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"})
|
|
}
|
|
|
|
// Get requested format
|
|
format := c.QueryParam("format")
|
|
if format == "" {
|
|
format = "html"
|
|
}
|
|
|
|
// Extract page content based on format
|
|
content, err := h.extractPageContent(c.Request().Context(), &mediaItem, page, format, fullPath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"content": content,
|
|
"page_number": page,
|
|
"total_pages": mediaItem.PageCount,
|
|
"media_item_id": mediaItemID,
|
|
})
|
|
}
|
|
|
|
// GetChapters returns chapter metadata
|
|
func (h *ReaderHandler) GetChapters(c echo.Context) error {
|
|
mediaItemID := c.Param("mediaItemId")
|
|
parsedUUID, err := uuid.Parse(mediaItemID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
|
|
}
|
|
|
|
// Use reader service to detect chapters
|
|
chapters, err := h.readerService.DetectChapters(c.Request().Context(), parsedUUID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to detect chapters"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"chapters": chapters,
|
|
})
|
|
}
|
|
|
|
// GetPanels returns panel detection data for comics
|
|
func (h *ReaderHandler) GetPanels(c echo.Context) error {
|
|
mediaItemID := c.Param("mediaItemId")
|
|
pageNumber := c.Param("pageNumber")
|
|
|
|
parsedUUID, err := uuid.Parse(mediaItemID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
|
|
}
|
|
|
|
page, err := strconv.Atoi(pageNumber)
|
|
if err != nil || page < 1 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"})
|
|
}
|
|
|
|
// Get detection method from query
|
|
method := c.QueryParam("method")
|
|
if method == "" {
|
|
method = "grid"
|
|
}
|
|
|
|
// Use reader service to detect panels
|
|
panels, err := h.readerService.DetectPanels(c.Request().Context(), parsedUUID, page, method)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to detect panels"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"page_number": page,
|
|
"detection_method": method,
|
|
"panels": panels,
|
|
})
|
|
}
|
|
|
|
// UpdatePanels allows manual panel override
|
|
func (h *ReaderHandler) UpdatePanels(c echo.Context) error {
|
|
mediaItemID := c.Param("mediaItemId")
|
|
pageNumber := c.Param("pageNumber")
|
|
|
|
parsedUUID, err := uuid.Parse(mediaItemID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
|
|
}
|
|
|
|
page, err := strconv.Atoi(pageNumber)
|
|
if err != nil || page < 1 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"})
|
|
}
|
|
|
|
// Parse request body
|
|
var req struct {
|
|
Panels []services.Panel `json:"panels"`
|
|
DetectionMethod string `json:"detection_method"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
|
}
|
|
|
|
// Store manual panel override in database
|
|
// This would require implementing UpsertPanelData in database/queries.sql
|
|
_ = parsedUUID
|
|
_ = page
|
|
_ = req
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"success": true,
|
|
"message": "Panels updated successfully",
|
|
})
|
|
}
|
|
|
|
// GetReadingSpeed retrieves reading speed statistics
|
|
func (h *ReaderHandler) GetReadingSpeed(c echo.Context) error {
|
|
mediaItemID := c.Param("mediaItemId")
|
|
parsedUUID, err := uuid.Parse(mediaItemID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
|
|
}
|
|
|
|
user := c.Get("user").(database.Users)
|
|
|
|
// Get reading speed from database
|
|
speed, err := h.db.GetReadingSpeed(c.Request().Context(), database.GetReadingSpeedParams{
|
|
UserID: user.ID,
|
|
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
// Return zero values if no reading has occurred
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"words_per_minute": 0,
|
|
"pages_per_minute": 0,
|
|
"pages_read": 0,
|
|
"total_reading_minutes": 0,
|
|
"last_read_at": nil,
|
|
})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch reading speed"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"words_per_minute": speed.PagesPerMinute.Float64 * 250, // Estimate WPM
|
|
"pages_per_minute": speed.PagesPerMinute.Float64,
|
|
"pages_read": speed.PagesRead,
|
|
"total_reading_minutes": speed.TotalReadingMinutes.Float64,
|
|
"last_read_at": speed.LastReadAt.Time,
|
|
})
|
|
}
|
|
|
|
// UpdateReadingSpeed updates reading speed statistics
|
|
func (h *ReaderHandler) UpdateReadingSpeed(c echo.Context) error {
|
|
mediaItemID := c.Param("mediaItemId")
|
|
parsedUUID, err := uuid.Parse(mediaItemID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
|
|
}
|
|
|
|
user := c.Get("user").(database.Users)
|
|
|
|
// Parse request body
|
|
var req struct {
|
|
PagesRead int `json:"pages_read"`
|
|
TimeSpentMinutes float64 `json:"time_spent_minutes"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
|
}
|
|
|
|
// Update reading speed using service
|
|
err = h.readerService.CalculateReadingSpeed(
|
|
c.Request().Context(),
|
|
user.ID,
|
|
parsedUUID,
|
|
req.PagesRead,
|
|
req.TimeSpentMinutes,
|
|
)
|
|
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update reading speed"})
|
|
}
|
|
|
|
// Calculate and return updated statistics
|
|
pagesPerMinute := float64(req.PagesRead) / req.TimeSpentMinutes
|
|
wordsPerMinute := pagesPerMinute * 250 // Estimate
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"success": true,
|
|
"words_per_minute": wordsPerMinute,
|
|
"pages_per_minute": pagesPerMinute,
|
|
})
|
|
}
|
|
|
|
// GetPDFOutline returns PDF outline/TOC
|
|
func (h *ReaderHandler) GetPDFOutline(c echo.Context) error {
|
|
mediaItemID := c.Param("mediaItemId")
|
|
parsedUUID, err := uuid.Parse(mediaItemID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
|
|
}
|
|
|
|
// Get media item
|
|
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
|
|
}
|
|
|
|
// Only PDFs have outlines
|
|
if mediaItem.FormatGroup != "pdf" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Not a PDF file"})
|
|
}
|
|
|
|
// Extract PDF outline using pdfcpu
|
|
fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"})
|
|
}
|
|
|
|
// Use pdfcpu to extract outline
|
|
outline := h.extractPDFOutline(fullPath)
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"outline": outline,
|
|
})
|
|
}
|
|
|
|
// GetPDFThumbnail returns a thumbnail for PDF mini-map
|
|
func (h *ReaderHandler) GetPDFThumbnail(c echo.Context) error {
|
|
mediaItemID := c.Param("mediaItemId")
|
|
pageNumber := c.Param("pageNumber")
|
|
|
|
parsedUUID, err := uuid.Parse(mediaItemID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
|
|
}
|
|
|
|
page, err := strconv.Atoi(pageNumber)
|
|
if err != nil || page < 1 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"})
|
|
}
|
|
|
|
// Get media item
|
|
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
|
|
}
|
|
|
|
// Get thumbnail size from query
|
|
width := c.QueryParam("width")
|
|
height := c.QueryParam("height")
|
|
|
|
// Generate thumbnail using pdfcpu
|
|
fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"})
|
|
}
|
|
|
|
thumbnail, err := h.generatePDFThumbnail(fullPath, page, width, height)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate thumbnail"})
|
|
}
|
|
|
|
c.Response().Header().Set("Content-Type", "image/png")
|
|
return c.Blob(http.StatusOK, "image/png", thumbnail)
|
|
}
|
|
|
|
// LookupWord performs dictionary lookup
|
|
func (h *ReaderHandler) LookupWord(c echo.Context) error {
|
|
word := c.Param("word")
|
|
if word == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Word parameter required"})
|
|
}
|
|
|
|
// Use reader service for dictionary lookup
|
|
entry, err := h.readerService.LookupWord(c.Request().Context(), word)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "Word not found in dictionary"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, entry)
|
|
}
|
|
|
|
// GetSettings retrieves user's reader settings
|
|
func (h *ReaderHandler) GetSettings(c echo.Context) error {
|
|
user := c.Get("user").(database.Users)
|
|
|
|
// Use reader service to get settings
|
|
settings, err := h.readerService.GetSettings(c.Request().Context(), user.ID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch settings"})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, settings)
|
|
}
|
|
|
|
// UpdateSettings updates user's reader settings
|
|
func (h *ReaderHandler) UpdateSettings(c echo.Context) error {
|
|
user := c.Get("user").(database.Users)
|
|
|
|
// Parse request body (partial update supported)
|
|
var settings map[string]interface{}
|
|
if err := c.Bind(&settings); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
|
}
|
|
|
|
// Validate settings
|
|
if readingTheme, ok := settings["reading_theme"].(string); ok {
|
|
validThemes := map[string]bool{
|
|
"light": true, "sepia": true, "dark": true, "night": true, "high-contrast": true,
|
|
}
|
|
if !validThemes[readingTheme] {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid reading theme"})
|
|
}
|
|
}
|
|
|
|
// Use reader service to update settings
|
|
err := h.readerService.UpdateSettings(c.Request().Context(), user.ID, settings)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update settings"})
|
|
}
|
|
|
|
// Return updated settings
|
|
updatedSettings, _ := h.readerService.GetSettings(c.Request().Context(), user.ID)
|
|
return c.JSON(http.StatusOK, updatedSettings)
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func (h *ReaderHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
|
|
// Get library folders
|
|
folders, err := h.db.GetLibraryFolders(ctx, libraryID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Try each folder until we find the file
|
|
for _, folder := range folders {
|
|
fullPath := folder.FolderPath + string(os.PathSeparator) + relativePath
|
|
if _, err := os.Stat(fullPath); err == nil {
|
|
return fullPath, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("file not found in any library folder")
|
|
}
|
|
|
|
func (h *ReaderHandler) extractPageContent(ctx context.Context, item *database.MediaItems, page int, format, fullPath string) (string, error) {
|
|
// Extract content based on format
|
|
// This is a simplified implementation
|
|
// In production, would use format-specific parsers
|
|
|
|
switch item.FormatGroup {
|
|
case "reflowable":
|
|
// For EPUB and other ebooks, extract the requested page/chapter
|
|
return h.extractEbookPage(fullPath, page, format)
|
|
case "fixed-layout":
|
|
// For comics, return image data URL or path
|
|
return h.extractComicPage(fullPath, page, format)
|
|
case "pdf":
|
|
// For PDFs, extract text or image
|
|
return h.extractPDFPage(fullPath, page, format)
|
|
default:
|
|
return "", fmt.Errorf("unsupported format: %s", item.FormatGroup)
|
|
}
|
|
}
|
|
|
|
func (h *ReaderHandler) extractEbookPage(fullPath string, page int, format string) (string, error) {
|
|
// Simplified EPUB extraction
|
|
// In production, would use epub-parser.ts logic
|
|
return fmt.Sprintf("<div class='ebook-page'><p>Page %d content</p></div>", page), nil
|
|
}
|
|
|
|
func (h *ReaderHandler) extractComicPage(fullPath string, page int, format string) (string, error) {
|
|
// For comics, return image path or data URL
|
|
return fmt.Sprintf("/readers/comic-image?page=%d", page), nil
|
|
}
|
|
|
|
func (h *ReaderHandler) extractPDFPage(fullPath string, page int, format string) (string, error) {
|
|
// For PDFs, extract text content or image
|
|
return fmt.Sprintf("<div class='pdf-page'><p>Page %d content</p></div>", page), nil
|
|
}
|
|
|
|
func (h *ReaderHandler) extractPDFOutline(fullPath string) []map[string]interface{} {
|
|
// Extract PDF outline using pdfcpu
|
|
// This is a placeholder
|
|
return []map[string]interface{}{}
|
|
}
|
|
|
|
func (h *ReaderHandler) generatePDFThumbnail(fullPath string, page int, width, height string) ([]byte, error) {
|
|
// Generate thumbnail using pdfcpu
|
|
// This is a placeholder
|
|
return []byte{}, nil
|
|
}
|
|
|
|
// Helper function to convert pgtype.Text to string
|
|
func textToString(t pgtype.Text) string {
|
|
if !t.Valid {
|
|
return ""
|
|
}
|
|
return t.String
|
|
}
|
|
```
|
|
|
|
### 3.3 Service Layer
|
|
|
|
**File:** `internal/services/reader_service.go` (new file)
|
|
|
|
All business logic goes here, not in handlers:
|
|
|
|
```go
|
|
package services
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
type ReaderService struct {
|
|
db *database.Queries
|
|
worker *Worker
|
|
}
|
|
|
|
func NewReaderService(db *database.Queries, worker *Worker) *ReaderService {
|
|
return &ReaderService{
|
|
db: db,
|
|
worker: worker,
|
|
}
|
|
}
|
|
|
|
// Chapter represents a detected chapter
|
|
type Chapter struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
StartPage int `json:"start_page"`
|
|
PageCount int `json:"page_count"`
|
|
Level int `json:"level"`
|
|
ParentID *string `json:"parent_id,omitempty"`
|
|
}
|
|
|
|
// Panel represents a detected comic panel
|
|
type Panel struct {
|
|
ID string `json:"id"`
|
|
X int `json:"x"`
|
|
Y int `json:"y"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
ReadingOrder int `json:"reading_order"`
|
|
}
|
|
|
|
// DictionaryEntry represents a word definition
|
|
type DictionaryEntry struct {
|
|
Word string `json:"word"`
|
|
Definition string `json:"definition"`
|
|
PartOfSpeech string `json:"part_of_speech,omitempty"`
|
|
Example string `json:"example,omitempty"`
|
|
Etymology string `json:"etymology,omitempty"`
|
|
}
|
|
|
|
// ChapterDetectionResult contains chapter metadata
|
|
type ChapterDetectionResult struct {
|
|
Chapters []Chapter `json:"chapters"`
|
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
|
DetectedAt time.Time `json:"detected_at"`
|
|
}
|
|
|
|
// DetectChapters analyzes a media item to detect chapter structure
|
|
func (s *ReaderService) DetectChapters(ctx context.Context, mediaItemID uuid.UUID) ([]Chapter, error) {
|
|
// Get media item to determine type
|
|
item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: mediaItemID, Valid: true})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get media item: %w", err)
|
|
}
|
|
|
|
// Check if chapter metadata already exists
|
|
if item.ChapterMetadata.Valid {
|
|
var existing ChapterDetectionResult
|
|
if err := json.Unmarshal(item.ChapterMetadata.Bytes, &existing); err == nil {
|
|
return existing.Chapters, nil
|
|
}
|
|
}
|
|
|
|
// Detect chapters based on format
|
|
var chapters []Chapter
|
|
|
|
switch item.FormatGroup {
|
|
case "reflowable":
|
|
// For ebooks, parse from TOC if available
|
|
chapters, err = s.detectEbookChapters(ctx, &item)
|
|
case "fixed-layout":
|
|
// For comics/manga, detect page breaks as chapters
|
|
chapters, err = s.detectComicChapters(ctx, &item)
|
|
case "pdf":
|
|
// For PDFs, use PDF outline
|
|
chapters, err = s.detectPDFChapters(ctx, &item)
|
|
default:
|
|
chapters = []Chapter{}
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("chapter detection failed: %w", err)
|
|
}
|
|
|
|
// Cache the results
|
|
result := ChapterDetectionResult{
|
|
Chapters: chapters,
|
|
Metadata: nil,
|
|
DetectedAt: time.Now(),
|
|
}
|
|
|
|
metadataBytes, err := json.Marshal(result)
|
|
if err == nil {
|
|
// Update media item with chapter metadata
|
|
// This would require a new query in database/queries.sql
|
|
_ = metadataBytes
|
|
}
|
|
|
|
return chapters, nil
|
|
}
|
|
|
|
func (s *ReaderService) detectEbookChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) {
|
|
// For EPUB files, parse the TOC from the OPF file
|
|
// This requires EPUB parsing (see epub-parser.ts)
|
|
// For now, return empty structure
|
|
return []Chapter{}, nil
|
|
}
|
|
|
|
func (s *ReaderService) detectComicChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) {
|
|
// For comics, treat each page as a potential chapter
|
|
// or group pages by story arcs if metadata exists
|
|
pageCount := int(item.PageCount.Int64)
|
|
|
|
if pageCount <= 0 {
|
|
return []Chapter{}, nil
|
|
}
|
|
|
|
chapters := make([]Chapter, 0)
|
|
chapterSize := 20 // Group pages into chapters of 20 pages each
|
|
|
|
for i := 0; i < pageCount; i += chapterSize {
|
|
endPage := i + chapterSize
|
|
if endPage > pageCount {
|
|
endPage = pageCount
|
|
}
|
|
|
|
chapters = append(chapters, Chapter{
|
|
ID: fmt.Sprintf("chapter-%d", len(chapters)+1),
|
|
Title: fmt.Sprintf("Page %d-%d", i+1, endPage),
|
|
StartPage: i + 1,
|
|
PageCount: endPage - i,
|
|
Level: 1,
|
|
})
|
|
}
|
|
|
|
return chapters, nil
|
|
}
|
|
|
|
func (s *ReaderService) detectPDFChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) {
|
|
// For PDFs, use pdfcpu to extract outline/bookmarks
|
|
// This requires PDF parsing library
|
|
return []Chapter{}, nil
|
|
}
|
|
|
|
// DetectPanels analyzes a comic page to detect panel boundaries
|
|
func (s *ReaderService) DetectPanels(
|
|
ctx context.Context,
|
|
mediaItemID uuid.UUID,
|
|
pageNumber int,
|
|
method string,
|
|
) ([]Panel, error) {
|
|
// Check if panels already exist in cache
|
|
cached, err := s.db.GetPanelData(ctx, database.GetPanelDataParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
|
PageNumber: int32(pageNumber),
|
|
})
|
|
|
|
if err == nil && cached.Valid {
|
|
var panels []Panel
|
|
if err := json.Unmarshal(cached.Bytes, &panels); err == nil {
|
|
return panels, nil
|
|
}
|
|
}
|
|
|
|
// Detect panels using specified method
|
|
var panels []Panel
|
|
|
|
switch method {
|
|
case "grid":
|
|
panels, err = s.detectPanelsGrid(ctx, mediaItemID, pageNumber)
|
|
case "ml":
|
|
panels, err = s.detectPanelsML(ctx, mediaItemID, pageNumber)
|
|
case "manual":
|
|
panels, err = s.detectPanelsManual(ctx, mediaItemID, pageNumber)
|
|
default:
|
|
// Default to grid detection
|
|
panels, err = s.detectPanelsGrid(ctx, mediaItemID, pageNumber)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("panel detection failed: %w", err)
|
|
}
|
|
|
|
// Cache the results
|
|
panelsJSON, _ := json.Marshal(panels)
|
|
// Insert into panel_data table
|
|
_ = panelsJSON
|
|
|
|
return panels, nil
|
|
}
|
|
|
|
func (s *ReaderService) detectPanelsGrid(
|
|
ctx context.Context,
|
|
mediaItemID uuid.UUID,
|
|
pageNumber int,
|
|
) ([]Panel, error) {
|
|
// Simple grid-based panel detection
|
|
// Divide page into 2x2 or 3x3 grid
|
|
// This is a simplified implementation
|
|
|
|
panels := []Panel{
|
|
{
|
|
ID: "panel-1",
|
|
X: 0,
|
|
Y: 0,
|
|
Width: 50,
|
|
Height: 100,
|
|
ReadingOrder: 1,
|
|
},
|
|
{
|
|
ID: "panel-2",
|
|
X: 50,
|
|
Y: 0,
|
|
Width: 50,
|
|
Height: 100,
|
|
ReadingOrder: 2,
|
|
},
|
|
}
|
|
|
|
return panels, nil
|
|
}
|
|
|
|
func (s *ReaderService) detectPanelsML(
|
|
ctx context.Context,
|
|
mediaItemID uuid.UUID,
|
|
pageNumber int,
|
|
) ([]Panel, error) {
|
|
// ML-based panel detection
|
|
// This would require a trained model
|
|
// For now, fall back to grid detection
|
|
return s.detectPanelsGrid(ctx, mediaItemID, pageNumber)
|
|
}
|
|
|
|
func (s *ReaderService) detectPanelsManual(
|
|
ctx context.Context,
|
|
mediaItemID uuid.UUID,
|
|
pageNumber int,
|
|
) ([]Panel, error) {
|
|
// Manual panel detection returns existing manually-set panels
|
|
// These would be stored in the panel_data table
|
|
return []Panel{}, nil
|
|
}
|
|
|
|
// CalculateReadingSpeed updates reading speed statistics
|
|
func (s *ReaderService) CalculateReadingSpeed(
|
|
ctx context.Context,
|
|
userID uuid.UUID,
|
|
mediaItemID uuid.UUID,
|
|
pagesRead int,
|
|
minutes float64,
|
|
) error {
|
|
if minutes <= 0 {
|
|
return fmt.Errorf("invalid time: must be positive")
|
|
}
|
|
|
|
pagesPerMinute := float64(pagesRead) / minutes
|
|
|
|
// Get or create reading speed record
|
|
_, err := s.db.GetReadingSpeed(ctx, database.GetReadingSpeedParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
// Create new record
|
|
_, err = s.db.CreateReadingSpeed(ctx, database.CreateReadingSpeedParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
|
PagesPerMinute: pgtype.Float8{Float64: pagesPerMinute, Valid: true},
|
|
PagesRead: int32(pagesRead),
|
|
TotalReadingMinutes: pgtype.Float8{Float64: minutes, Valid: true},
|
|
LastReadAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
|
})
|
|
} else {
|
|
// Update existing record with moving average
|
|
// This would require an UpdateReadingSpeed query
|
|
_ = pagesPerMinute
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// LookupWord retrieves dictionary entry for a word
|
|
func (s *ReaderService) LookupWord(ctx context.Context, word string) (*DictionaryEntry, error) {
|
|
// Check cache first
|
|
cached, err := s.db.GetDictionaryEntry(ctx, word)
|
|
|
|
if err == nil {
|
|
return &DictionaryEntry{
|
|
Word: cached.Word,
|
|
Definition: cached.Definition,
|
|
PartOfSpeech: cached.PartOfSpeech.String,
|
|
Example: cached.Example.String,
|
|
Etymology: cached.Etymology.String,
|
|
}, nil
|
|
}
|
|
|
|
// Not in cache, fetch from dictionary API
|
|
entry, err := s.fetchDictionaryEntry(ctx, word)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Cache the entry
|
|
_, _ = s.db.CreateDictionaryEntry(ctx, database.CreateDictionaryEntryParams{
|
|
Word: entry.Word,
|
|
Definition: entry.Definition,
|
|
PartOfSpeech: pgtype.Text{String: entry.PartOfSpeech, Valid: entry.PartOfSpeech != ""},
|
|
Example: pgtype.Text{String: entry.Example, Valid: entry.Example != ""},
|
|
Etymology: pgtype.Text{String: entry.Etymology, Valid: entry.Etymology != ""},
|
|
})
|
|
|
|
return entry, nil
|
|
}
|
|
|
|
func (s *ReaderService) fetchDictionaryEntry(ctx context.Context, word string) (*DictionaryEntry, error) {
|
|
// Fetch from external dictionary API
|
|
// For now, return a placeholder
|
|
return &DictionaryEntry{
|
|
Word: word,
|
|
Definition: fmt.Sprintf("Definition for %s", word),
|
|
}, nil
|
|
}
|
|
|
|
// GetSettings retrieves reader settings for a user
|
|
func (s *ReaderService) GetSettings(
|
|
ctx context.Context,
|
|
userID uuid.UUID,
|
|
) (map[string]interface{}, error) {
|
|
// Get settings from database
|
|
settings, err := s.db.GetReaderSettings(ctx, pgtype.UUID{Bytes: userID, Valid: true})
|
|
|
|
if err != nil {
|
|
// Return default settings
|
|
return s.getDefaultSettings(), nil
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(settings.Bytes, &result); err != nil {
|
|
return s.getDefaultSettings(), nil
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// UpdateSettings updates reader settings for a user
|
|
func (s *ReaderService) UpdateSettings(
|
|
ctx context.Context,
|
|
userID uuid.UUID,
|
|
settings map[string]interface{},
|
|
) error {
|
|
// Merge with existing settings
|
|
existing, err := s.GetSettings(ctx, userID)
|
|
if err != nil {
|
|
existing = s.getDefaultSettings()
|
|
}
|
|
|
|
// Merge settings (partial update)
|
|
for key, value := range settings {
|
|
existing[key] = value
|
|
}
|
|
|
|
// Serialize and save
|
|
settingsJSON, err := json.Marshal(existing)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to serialize settings: %w", err)
|
|
}
|
|
|
|
// Update in database
|
|
_, err = s.db.UpsertReaderSettings(ctx, database.UpsertReaderSettingsParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
SettingKey: "reader_settings",
|
|
SettingValue: string(settingsJSON),
|
|
})
|
|
|
|
return err
|
|
}
|
|
|
|
func (s *ReaderService) getDefaultSettings() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"chrome_behavior": "auto-hide",
|
|
"progress_mode": "pages",
|
|
"chrome_theme": "tokyo-night",
|
|
"reading_theme": "dark",
|
|
"reading_font": "literata",
|
|
"font_size": 16,
|
|
"line_height": 1.6,
|
|
"margin_width": 20,
|
|
"tap_zone_size": 30,
|
|
"auto_scroll": false,
|
|
"panel_zoom_enabled": true,
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Frontend Implementation
|
|
|
|
### 4.1 File Structure
|
|
|
|
```
|
|
web/src/reader/
|
|
├── reader.ts - Main reader entry point
|
|
├── reader-shell.ts - UI shell, chrome control
|
|
├── progress-indicator.ts - KOReader-style switchable progress
|
|
├── settings-manager.ts - Settings (DB + localStorage)
|
|
├── slide-in-panel.ts - Shared slide-in panel (TOC + Settings)
|
|
├── annotation-manager.ts - Highlights, notes, bookmarks
|
|
├── websocket-sync.ts - Reuse existing websocket.ts
|
|
├── dictionary-popup.ts - Offline dictionary lookup
|
|
│
|
|
├── ebook/
|
|
│ ├── epub-parser.ts - EPUB parsing (ZIP + XML)
|
|
│ ├── html-renderer.ts - Browser-native rendering
|
|
│ ├── cfi-navigator.ts - EPUB CFI navigation
|
|
│ ├── typography-engine.ts - Font rendering, themes
|
|
│ └── chapter-detector.ts - Chapter detection
|
|
│
|
|
├── comic/
|
|
│ ├── image-parser.ts - CBZ/CBR/PDF parsing
|
|
│ ├── canvas-renderer.ts - Canvas rendering
|
|
│ ├── panel-detector.ts - Grid + ML + manual
|
|
│ ├── panel-navigator.ts - Panel zoom animations
|
|
│ └── page-cache.ts - 5-page ahead cache
|
|
│
|
|
└── manga/
|
|
├── rtl-navigator.ts - Right-to-left navigation
|
|
└── vertical-scroll.ts - Webtoon-style scroll
|
|
```
|
|
|
|
### 4.2 TypeScript Types
|
|
|
|
**File:** `web/src/types/reader.d.ts` (new file)
|
|
|
|
```typescript
|
|
// ============================================================
|
|
// Common Intermediate Format (CIF) Types
|
|
// Universal format for all reflowable ebooks after parsing
|
|
// ============================================================
|
|
|
|
interface EbookCIF {
|
|
// Universal metadata (all formats)
|
|
metadata: {
|
|
title: string;
|
|
author: string;
|
|
language: string;
|
|
publisher?: string;
|
|
isbn?: string;
|
|
coverImage?: Blob;
|
|
};
|
|
|
|
// Unified navigation structure
|
|
toc: TOCNode[];
|
|
|
|
// Content spine (reading order)
|
|
spine: SpineItem[];
|
|
|
|
// Resources (CSS, fonts, images)
|
|
resources: Map<string, Blob>;
|
|
|
|
// Progress tracking (minimal - backend handles detailed tracking)
|
|
locations: {
|
|
totalCharacters: number;
|
|
estimatedPages: number;
|
|
};
|
|
}
|
|
|
|
interface SpineItem {
|
|
id: string;
|
|
type: 'html' | 'image';
|
|
content: string;
|
|
properties?: string[];
|
|
|
|
// Minimal position info for UI
|
|
index: number;
|
|
}
|
|
|
|
interface TOCNode {
|
|
id: string;
|
|
title: string;
|
|
href: string;
|
|
children: TOCNode[];
|
|
}
|
|
|
|
// ============================================================
|
|
// Parser Types (Procedural, not OOP)
|
|
// ============================================================
|
|
|
|
type ParserFormat = 'epub' | 'fb2' | 'txt' | 'html' | 'mobi' | 'azw3' | 'docx' | 'rtf';
|
|
|
|
interface ParserCapabilities {
|
|
canParse(mimeType: string, extension: string): boolean;
|
|
parse(file: Blob): Promise<EbookCIF>;
|
|
extractMetadata(file: Blob): Promise<Partial<EbookCIF['metadata']>>;
|
|
}
|
|
|
|
// ============================================================
|
|
// Reader Metadata (from API)
|
|
// ============================================================
|
|
|
|
interface ReaderMetadata {
|
|
media_item_id: string;
|
|
title: string;
|
|
author: string;
|
|
cover_image_path: string;
|
|
library_type: 'ebook' | 'comic' | 'manga' | 'pdf';
|
|
mime_type: string;
|
|
file_path: string;
|
|
chapter_metadata?: ChapterMetadata;
|
|
total_pages?: number;
|
|
}
|
|
|
|
// ============================================================
|
|
// Other Shared Types
|
|
// ============================================================
|
|
|
|
interface ChapterMetadata {
|
|
chapters: Chapter[];
|
|
}
|
|
|
|
interface Chapter {
|
|
id: string;
|
|
title: string;
|
|
start_page: number;
|
|
page_count: number;
|
|
}
|
|
|
|
interface PanelData {
|
|
media_item_id: string;
|
|
page_number: number;
|
|
detection_method: 'grid' | 'ml' | 'manual';
|
|
panels: Panel[];
|
|
updated_at: string;
|
|
}
|
|
|
|
interface Panel {
|
|
id: string;
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
reading_order: number;
|
|
}
|
|
|
|
interface ReadingSpeed {
|
|
words_per_minute: number;
|
|
pages_per_minute: number;
|
|
pages_read: number;
|
|
total_reading_minutes: number;
|
|
last_read_at: string;
|
|
}
|
|
|
|
interface DictionaryEntry {
|
|
word: string;
|
|
definition: string;
|
|
part_of_speech?: string;
|
|
example?: string;
|
|
etymology?: string;
|
|
}
|
|
|
|
interface ReaderSettings {
|
|
chrome_behavior: 'auto-hide' | 'always-visible' | 'hide-on-scroll';
|
|
progress_mode: 'pages' | 'chapter' | 'percentage' | 'time-left';
|
|
|
|
chrome_theme: string;
|
|
reading_theme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast';
|
|
|
|
reading_font: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex';
|
|
font_size: number;
|
|
line_height: number;
|
|
margin_width: number;
|
|
|
|
tap_zone_size: number;
|
|
auto_scroll: boolean;
|
|
panel_zoom_enabled: boolean;
|
|
|
|
double_page_spread: boolean;
|
|
reading_direction: 'ltr' | 'rtl' | 'vertical';
|
|
|
|
pdf_fit_mode: 'fit-width' | 'fit-page' | 'fit-height' | 'none';
|
|
pdf_zoom_level: number;
|
|
pdf_text_layer_enabled: boolean;
|
|
pdf_dual_page_mode: 'auto' | 'single' | 'dual';
|
|
pdf_dual_page_threshold: number;
|
|
pdf_minimap_enabled: boolean;
|
|
pdf_outline_visible: boolean;
|
|
media_bookmarks_visible: boolean;
|
|
|
|
hardware_acceleration: boolean;
|
|
|
|
// Dockable panel layout configuration (per-user, media-type-aware)
|
|
panel_layout: PanelLayoutSettings;
|
|
}
|
|
|
|
interface PanelLayoutSettings {
|
|
// Per panel state: which side, visible, collapsed (window-shade), width
|
|
toc: PanelState;
|
|
settings: PanelState;
|
|
navigator: PanelState;
|
|
bookmarks: PanelState;
|
|
|
|
// Mobile override
|
|
mobile_nav_visible: boolean;
|
|
}
|
|
|
|
interface PanelState {
|
|
side: 'left' | 'right' | 'hidden';
|
|
visible: boolean;
|
|
collapsed: boolean; // Window-shade: true = collapsed to title bar
|
|
width_px: number; // Panel width in pixels
|
|
order: number; // Order within the side
|
|
locked: boolean; // Lock toggle: prevents accidental drag/move
|
|
last_valid_side: 'left' | 'right' | 'hidden'; // Snap-back target if dropped in invalid area
|
|
}
|
|
|
|
interface ProgressDisplay {
|
|
mode: 'pages' | 'chapter' | 'percentage' | 'time-left';
|
|
current: number;
|
|
total: number;
|
|
label?: string;
|
|
time_left?: string;
|
|
}
|
|
```
|
|
|
|
### 4.3 Universal Reader Shell (Procedural)
|
|
|
|
**File:** `web/src/reader/reader-shell.ts`
|
|
|
|
```typescript
|
|
// Universal Reader Shell - Routes to appropriate reader
|
|
// Procedural style: Functions, not classes
|
|
|
|
import { Alpine } from "../alpine";
|
|
import { getReaderMetadata, updateReadingProgress } from "./api";
|
|
import { SettingsManager } from "./settings-manager";
|
|
import { ProgressIndicator } from "./progress-indicator";
|
|
import { parseEbook, requiresServerParsing } from './parser-manager';
|
|
import { initializePDFReader } from './pdf/pdfjs-wrapper';
|
|
import { initializeComicReader } from './comic/image-parser';
|
|
|
|
// ============================================================
|
|
// Reader State
|
|
// ============================================================
|
|
|
|
let currentReader: UniversalReader | PDFReader | ComicReader | MangaReader | null = null;
|
|
let readerMetadata: ReaderMetadata | null = null;
|
|
|
|
interface UniversalReader {
|
|
type: 'ebook';
|
|
cif: EbookCIF;
|
|
currentSpineIndex: number;
|
|
}
|
|
|
|
interface PDFReader {
|
|
type: 'pdf';
|
|
doc: any;
|
|
currentPage: number;
|
|
}
|
|
|
|
interface ComicReader {
|
|
type: 'comic';
|
|
images: Blob[];
|
|
currentPage: number;
|
|
}
|
|
|
|
interface MangaReader {
|
|
type: 'manga';
|
|
images: Blob[];
|
|
currentPage: number;
|
|
readingDirection: 'rtl' | 'vertical';
|
|
}
|
|
|
|
// ============================================================
|
|
// Initialization
|
|
// ============================================================
|
|
|
|
async function initializeReader(): Promise<void> {
|
|
const mediaItemId = document.body.dataset.mediaItemId;
|
|
if (!mediaItemId) return;
|
|
|
|
// Fetch metadata
|
|
readerMetadata = await getReaderMetadata(mediaItemId);
|
|
|
|
// Initialize appropriate reader based on type
|
|
switch (readerMetadata.library_type) {
|
|
case 'ebook':
|
|
currentReader = await initializeEbookReader(readerMetadata);
|
|
break;
|
|
case 'pdf':
|
|
currentReader = await initializePDFReader(readerMetadata);
|
|
break;
|
|
case 'comic':
|
|
currentReader = await initializeComicReader(readerMetadata);
|
|
break;
|
|
case 'manga':
|
|
currentReader = await initializeMangaReader(readerMetadata);
|
|
break;
|
|
}
|
|
|
|
if (currentReader) {
|
|
setupReaderUI();
|
|
}
|
|
}
|
|
|
|
async function initializeEbookReader(metadata: ReaderMetadata): Promise<UniversalReader> {
|
|
// Check if server-side parsing is needed
|
|
const needsServer = requiresServerParsing(metadata.mime_type, getFileExtension(metadata.file_path));
|
|
|
|
let ebookFile: Blob;
|
|
|
|
if (needsServer) {
|
|
// Fetch parsed CIF from server
|
|
const response = await fetch(`/readers/${metadata.media_item_id}/parse`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
mime_type: metadata.mime_type,
|
|
file_path: metadata.file_path,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Server parsing failed: ${response.statusText}`);
|
|
}
|
|
|
|
ebookFile = await response.blob();
|
|
} else {
|
|
// Fetch original file for client-side parsing
|
|
const response = await fetch(metadata.file_path);
|
|
ebookFile = await response.blob();
|
|
}
|
|
|
|
// Parse ebook to CIF
|
|
const cif = await parseEbook(ebookFile, metadata.mime_type, getFileExtension(metadata.file_path));
|
|
|
|
return {
|
|
type: 'ebook',
|
|
cif,
|
|
currentSpineIndex: 0,
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// UI Setup
|
|
// ============================================================
|
|
|
|
function setupReaderUI(): void {
|
|
if (!currentReader || !readerMetadata) return;
|
|
|
|
// Setup chrome
|
|
setupChromeBehavior();
|
|
|
|
// Setup progress indicator
|
|
setupProgressIndicator();
|
|
|
|
// Setup annotations
|
|
setupAnnotations();
|
|
|
|
// Setup keyboard navigation
|
|
setupKeyboardNavigation();
|
|
}
|
|
|
|
function setupChromeBehavior(): void {
|
|
const chrome = document.getElementById('reader-chrome');
|
|
if (!chrome) return;
|
|
|
|
// Auto-hide on scroll
|
|
let hideTimeout: NodeJS.Timeout;
|
|
|
|
window.addEventListener('scroll', () => {
|
|
chrome.classList.add('visible');
|
|
|
|
clearTimeout(hideTimeout);
|
|
hideTimeout = setTimeout(() => {
|
|
chrome.classList.remove('visible');
|
|
}, 2000);
|
|
});
|
|
|
|
// Toggle on tap (for touch devices)
|
|
chrome.addEventListener('click', () => {
|
|
chrome.classList.toggle('visible');
|
|
});
|
|
}
|
|
|
|
function setupProgressIndicator(): void {
|
|
// Update progress based on reader type
|
|
if (!currentReader) return;
|
|
|
|
if (currentReader.type === 'ebook') {
|
|
updateEbookProgress(currentReader.cif, currentReader.currentSpineIndex);
|
|
} else if (currentReader.type === 'pdf') {
|
|
updatePDFProgress(currentReader.currentPage, readerMetadata.total_pages || 0);
|
|
} else if (currentReader.type === 'comic' || currentReader.type === 'manga') {
|
|
updateComicProgress(currentReader.currentPage, currentReader.images.length);
|
|
}
|
|
}
|
|
|
|
function setupAnnotations(): void {
|
|
// Load existing highlights and notes
|
|
// Implementation depends on annotation system
|
|
}
|
|
|
|
function setupKeyboardNavigation(): void {
|
|
document.addEventListener('keydown', (e) => {
|
|
if (!currentReader) return;
|
|
|
|
switch (e.key) {
|
|
case 'ArrowRight':
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
nextPage();
|
|
break;
|
|
case 'ArrowLeft':
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
previousPage();
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Navigation Functions
|
|
// ============================================================
|
|
|
|
function nextPage(): void {
|
|
if (!currentReader) return;
|
|
|
|
if (currentReader.type === 'ebook') {
|
|
nextSpineItem();
|
|
} else if (currentReader.type === 'pdf') {
|
|
nextPDFPage();
|
|
} else if (currentReader.type === 'comic' || currentReader.type === 'manga') {
|
|
nextComicPage();
|
|
}
|
|
}
|
|
|
|
function previousPage(): void {
|
|
if (!currentReader) return;
|
|
|
|
if (currentReader.type === 'ebook') {
|
|
previousSpineItem();
|
|
} else if (currentReader.type === 'pdf') {
|
|
previousPDFPage();
|
|
} else if (currentReader.type === 'comic' || currentReader.type === 'manga') {
|
|
previousComicPage();
|
|
}
|
|
}
|
|
|
|
function nextSpineItem(): void {
|
|
if (currentReader?.type !== 'ebook') return;
|
|
|
|
if (currentReader.currentSpineIndex < currentReader.cif.spine.length - 1) {
|
|
currentReader.currentSpineIndex++;
|
|
renderCurrentSpineItem();
|
|
}
|
|
}
|
|
|
|
function previousSpineItem(): void {
|
|
if (currentReader?.type !== 'ebook') return;
|
|
|
|
if (currentReader.currentSpineIndex > 0) {
|
|
currentReader.currentSpineIndex--;
|
|
renderCurrentSpineItem();
|
|
}
|
|
}
|
|
|
|
function renderCurrentSpineItem(): void {
|
|
if (currentReader?.type !== 'ebook') return;
|
|
|
|
const spineItem = currentReader.cif.spine[currentReader.currentSpineIndex];
|
|
const container = document.getElementById('reader-content');
|
|
|
|
if (!container) return;
|
|
|
|
// Render spine item content
|
|
container.innerHTML = spineItem.content;
|
|
|
|
// Apply theme and typography
|
|
applyReaderTheme();
|
|
applyTypography();
|
|
|
|
// Update progress
|
|
updateProgress();
|
|
}
|
|
|
|
// ============================================================
|
|
// Progress Tracking
|
|
// ============================================================
|
|
|
|
function updateProgress(): void {
|
|
if (!currentReader || !readerMetadata) return;
|
|
|
|
let percentage = 0;
|
|
let currentPosition = '';
|
|
|
|
if (currentReader.type === 'ebook') {
|
|
const totalSpine = currentReader.cif.spine.length;
|
|
percentage = (currentReader.currentSpineIndex + 1) / totalSpine;
|
|
currentPosition = `spine:${currentReader.currentSpineIndex}`;
|
|
} else if (currentReader.type === 'pdf') {
|
|
const totalPages = readerMetadata.total_pages || 1;
|
|
percentage = currentReader.currentPage / totalPages;
|
|
currentPosition = `page:${currentReader.currentPage}`;
|
|
} else if (currentReader.type === 'comic' || currentReader.type === 'manga') {
|
|
const totalPages = currentReader.images.length;
|
|
percentage = currentReader.currentPage / totalPages;
|
|
currentPosition = `page:${currentReader.currentPage}`;
|
|
}
|
|
|
|
// Send to backend
|
|
updateReadingProgress(readerMetadata.media_item_id, {
|
|
percentage,
|
|
current_page: currentReader.type === 'ebook' ? currentReader.currentSpineIndex : currentReader.currentPage,
|
|
position: currentPosition,
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Alpine.js Integration
|
|
// ============================================================
|
|
|
|
Alpine.data('readerShell', () => ({
|
|
init() {
|
|
initializeReader();
|
|
},
|
|
|
|
nextPage,
|
|
previousPage,
|
|
|
|
get currentPage() {
|
|
if (!currentReader) return 0;
|
|
|
|
if (currentReader.type === 'ebook') {
|
|
return currentReader.currentSpineIndex + 1;
|
|
} else {
|
|
return currentReader.currentPage;
|
|
}
|
|
},
|
|
|
|
get totalPages() {
|
|
if (!currentReader || !readerMetadata) return 0;
|
|
|
|
if (currentReader.type === 'ebook') {
|
|
return currentReader.cif.spine.length;
|
|
} else if (currentReader.type === 'pdf') {
|
|
return readerMetadata.total_pages || 0;
|
|
} else {
|
|
return currentReader.images.length;
|
|
}
|
|
},
|
|
}));
|
|
|
|
// ============================================================
|
|
// Utility Functions
|
|
// ============================================================
|
|
|
|
function getFileExtension(filepath: string): string {
|
|
const match = filepath.match(/\.([^.]+)$/);
|
|
return match ? `.${match[1]}` : '';
|
|
}
|
|
|
|
function applyReaderTheme(): void {
|
|
// Apply reading theme from settings
|
|
const settings = getReaderSettings();
|
|
|
|
const container = document.getElementById('reader-content');
|
|
if (!container) return;
|
|
|
|
container.className = `ebook-content theme-${settings.reading_theme}`;
|
|
}
|
|
|
|
function applyTypography(): void {
|
|
const settings = getReaderSettings();
|
|
const container = document.getElementById('reader-content');
|
|
if (!container) return;
|
|
|
|
container.style.fontSize = `${settings.font_size}px`;
|
|
container.style.lineHeight = settings.line_height.toString();
|
|
container.style.fontFamily = getFontStack(settings.reading_font);
|
|
}
|
|
|
|
function getFontStack(font: string): string {
|
|
const stacks: Record<string, string> = {
|
|
'literata': '"Literata", serif',
|
|
'crimson': '"Crimson Text", serif',
|
|
'source-serif': '"Source Serif 4", serif',
|
|
'eb-garamond': '"EB Garamond", serif',
|
|
'libertinus': '"Libertinus Serif", serif',
|
|
'noto-serif': '"Noto Serif", serif',
|
|
'charis-sil': '"Charis SIL", serif',
|
|
'ibm-plex': '"IBM Plex Serif", serif',
|
|
};
|
|
|
|
return stacks[font] || stacks['literata'];
|
|
}
|
|
|
|
function getReaderSettings(): ReaderSettings {
|
|
// Load from settings manager
|
|
return {} as ReaderSettings; // Simplified
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 4.4 Server-Side Parsers (Go Backend)
|
|
|
|
**PHASE 1 SCOPE:** Stub only - returns "not implemented" for complex formats
|
|
|
|
**File:** `internal/handlers/reader.go` (add ParseEbook method)
|
|
|
|
```go
|
|
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/services"
|
|
"fmt"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// ParseEbook handles server-side parsing for complex ebook formats
|
|
// **PHASE 1:** Returns 501 "not implemented" for MOBI/AZW3/DOCX/RTF
|
|
// **PHASE 2.5:** Implement actual parsing logic
|
|
//
|
|
// **NOTE:** This method is NOT registered as a route in Phase 1
|
|
// Route registration happens in Phase 2.5
|
|
func (h *ReaderHandler) ParseEbook(c echo.Context) error {
|
|
mediaItemID := c.Param("mediaItemId")
|
|
parsedUUID, err := uuid.Parse(mediaItemID)
|
|
if err != nil {
|
|
return c.JSON(400, map[string]string{"error": "Invalid media item ID"})
|
|
}
|
|
|
|
// Fetch media item
|
|
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return c.JSON(404, map[string]string{"error": "Media item not found"})
|
|
}
|
|
return c.JSON(500, map[string]string{"error": "Failed to fetch media item"})
|
|
}
|
|
|
|
// Check if format requires server-side parsing
|
|
requiresServerParsing := false
|
|
formatName := ""
|
|
switch mediaItem.MimeType.String {
|
|
case "application/x-mobipocket-ebook":
|
|
requiresServerParsing = true
|
|
formatName = "MOBI"
|
|
case "application/vnd.amazon.mobi8-ebook":
|
|
requiresServerParsing = true
|
|
formatName = "AZW3/KF8"
|
|
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
|
requiresServerParsing = true
|
|
formatName = "DOCX"
|
|
case "application/rtf":
|
|
requiresServerParsing = true
|
|
formatName = "RTF"
|
|
}
|
|
|
|
if requiresServerParsing {
|
|
// Phase 1: Return 501 for server-side formats
|
|
return c.JSON(501, map[string]interface{}{
|
|
"error": fmt.Sprintf("Server-side %s parsing is not yet implemented", formatName),
|
|
"message": fmt.Sprintf("%s format support is planned for Phase 2.5", formatName),
|
|
"suggestion": "Please convert your ebook to EPUB format for now",
|
|
"format": formatName,
|
|
"phase": "2.5",
|
|
})
|
|
}
|
|
|
|
// Client-side format - should not call this endpoint
|
|
return c.JSON(400, map[string]string{
|
|
"error": "This format should be parsed client-side, not on the server",
|
|
})
|
|
}
|
|
```
|
|
|
|
**PHASE 1:** That's it - just the stub above. No parser implementations needed.
|
|
|
|
**PHASE 2.5:** See Section 14.5 for complete parser implementations (parseMOBI, parseAZW3, parseDOCX, parseRTF)
|
|
|
|
---
|
|
|
|
### 4.5 Progress Indicator (KOReader-style)
|
|
|
|
**File:** `web/src/reader/progress-indicator.ts`
|
|
|
|
```typescript
|
|
// KOReader-style switchable progress indicator
|
|
|
|
import { Alpine } from "../alpine";
|
|
import { getReadingSpeed } from "./api";
|
|
|
|
interface ProgressDisplay {
|
|
mode: 'pages' | 'chapter' | 'percentage' | 'time-left';
|
|
text: string;
|
|
}
|
|
|
|
function calculateProgress(
|
|
currentPage: number,
|
|
totalPages: number,
|
|
currentChapterPage: number,
|
|
chapterPages: number,
|
|
readingSpeed?: ReadingSpeed
|
|
): ProgressDisplay {
|
|
const mode = getCurrentProgressMode(); // From settings
|
|
|
|
switch (mode) {
|
|
case 'pages':
|
|
return {
|
|
mode: 'pages',
|
|
text: `${currentPage}/${totalPages}`
|
|
};
|
|
|
|
case 'chapter':
|
|
return {
|
|
mode: 'chapter',
|
|
text: `${currentChapterPage}/${chapterPages}`
|
|
};
|
|
|
|
case 'percentage':
|
|
const percentage = Math.round((currentPage / totalPages) * 100);
|
|
return {
|
|
mode: 'percentage',
|
|
text: `${percentage}%`
|
|
};
|
|
|
|
case 'time-left':
|
|
if (!readingSpeed) {
|
|
return { mode: 'time-left', text: '--:--' };
|
|
}
|
|
const pagesLeft = totalPages - currentPage;
|
|
const minutesLeft = pagesLeft / readingSpeed.pages_per_minute;
|
|
const hours = Math.floor(minutesLeft / 60);
|
|
const mins = Math.round(minutesLeft % 60);
|
|
return {
|
|
mode: 'time-left',
|
|
text: `${hours}h ${mins}m`
|
|
};
|
|
}
|
|
}
|
|
|
|
function cycleProgressMode(): void {
|
|
const modes: Array<'pages' | 'chapter' | 'percentage' | 'time-left'> =
|
|
['pages', 'chapter', 'percentage', 'time-left'];
|
|
const currentMode = getCurrentProgressMode();
|
|
const currentIndex = modes.indexOf(currentMode);
|
|
const nextMode = modes[(currentIndex + 1) % modes.length];
|
|
setProgressMode(nextMode);
|
|
}
|
|
```
|
|
|
|
### 4.6 Settings Manager (DB + localStorage)
|
|
|
|
**File:** `web/src/reader/settings-manager.ts`
|
|
|
|
```typescript
|
|
// Per-user settings with localStorage fallback
|
|
|
|
import { apiGet, apiPut } from "../api";
|
|
import { getToken, setItem, getItem } from "../storage";
|
|
|
|
const SETTINGS_KEY = 'reader_settings';
|
|
const LOCALSTORAGE_KEY = 'reader_settings_local';
|
|
|
|
interface SettingsManager {
|
|
load(): Promise<ReaderSettings>;
|
|
save(settings: Partial<ReaderSettings>): Promise<void>;
|
|
sync(): Promise<void>; // Sync localStorage → DB
|
|
get(key: keyof ReaderSettings): any;
|
|
set(key: keyof ReaderSettings, value: any): Promise<void>;
|
|
}
|
|
|
|
async function loadSettings(): Promise<ReaderSettings> {
|
|
const token = getToken();
|
|
if (!token) {
|
|
// Fallback to localStorage
|
|
const local = getItem(LOCALSTORAGE_KEY);
|
|
return local ? JSON.parse(local) : getDefaultSettings();
|
|
}
|
|
|
|
try {
|
|
const response = await apiGet('/readers/settings');
|
|
const settings = await response.json();
|
|
// Cache in localStorage
|
|
setItem(LOCALSTORAGE_KEY, JSON.stringify(settings));
|
|
return settings;
|
|
} catch (error) {
|
|
// Fallback to localStorage on error
|
|
const local = getItem(LOCALSTORAGE_KEY);
|
|
return local ? JSON.parse(local) : getDefaultSettings();
|
|
}
|
|
}
|
|
|
|
async function saveSettings(settings: Partial<ReaderSettings>): Promise<void> {
|
|
const token = getToken();
|
|
if (!token) {
|
|
// Save to localStorage only
|
|
const current = loadSettings();
|
|
const updated = { ...current, ...settings };
|
|
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await apiPut('/readers/settings', settings);
|
|
// Update localStorage cache
|
|
const current = loadSettings();
|
|
const updated = { ...current, ...settings };
|
|
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
|
|
} catch (error) {
|
|
// Fallback to localStorage
|
|
const current = loadSettings();
|
|
const updated = { ...current, ...settings };
|
|
setItem(LOCALSTORAGE_KEY, JSON.stringify(updated));
|
|
}
|
|
}
|
|
|
|
function getDefaultSettings(): ReaderSettings {
|
|
return {
|
|
chrome_behavior: 'auto-hide',
|
|
progress_mode: 'pages',
|
|
chrome_theme: 'tokyo-night', // UI chrome: All 11 themes available
|
|
reading_theme: 'dark', // Ebook text: 5 reading-optimized themes
|
|
reading_font: 'literata', // Default reading font (designed for ebooks)
|
|
tap_zone_size: 30,
|
|
auto_scroll: false,
|
|
panel_zoom_enabled: true,
|
|
font_size: 16,
|
|
line_height: 1.6,
|
|
margin_width: 20,
|
|
double_page_spread: false,
|
|
reading_direction: 'ltr',
|
|
hardware_acceleration: true,
|
|
|
|
// Dockable panel defaults by media type
|
|
panel_layout: {
|
|
toc: { side: 'left', visible: true, collapsed: false, width_px: 320, order: 1, locked: false, last_valid_side: 'left' },
|
|
settings: { side: 'left', visible: false, collapsed: true, width_px: 380, order: 2, locked: false, last_valid_side: 'left' },
|
|
navigator: { side: 'right', visible: true, collapsed: false, width_px: 200, order: 1, locked: false, last_valid_side: 'right' },
|
|
bookmarks: { side: 'right', visible: false, collapsed: true, width_px: 280, order: 2, locked: false, last_valid_side: 'right' },
|
|
mobile_nav_visible: false
|
|
}
|
|
};
|
|
}
|
|
```
|
|
|
|
### 4.7 Panel Dock System (Modular Dockable Panels)
|
|
|
|
**File:** `web/src/reader/panel-dock-system.ts`
|
|
|
|
```typescript
|
|
// Modular dockable panel system - handles drag, lock, snap-back, window-shade
|
|
// Procedural style: Functions, not classes
|
|
|
|
import { saveSettings, loadSettings, getDefaultSettings } from "./settings-manager";
|
|
|
|
interface PanelDockState {
|
|
panels: Map<string, PanelState>;
|
|
dragState: DragState | null;
|
|
dockZones: DockZone[];
|
|
}
|
|
|
|
interface DragState {
|
|
panelId: string;
|
|
startX: number;
|
|
startY: number;
|
|
currentX: number;
|
|
currentY: number;
|
|
isLocked: boolean;
|
|
}
|
|
|
|
interface DockZone {
|
|
side: 'left' | 'right';
|
|
x: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
const state: PanelDockState = {
|
|
panels: new Map(),
|
|
dragState: null,
|
|
dockZones: [
|
|
{ side: 'left', x: 0, width: 400, height: window.innerHeight },
|
|
{ side: 'right', x: window.innerWidth - 400, width: 400, height: window.innerHeight }
|
|
]
|
|
};
|
|
|
|
// Initialize all panels from settings
|
|
function initializePanelDockSystem(): void {
|
|
const settings = loadSettings();
|
|
|
|
for (const [panelId, panelState] of Object.entries(settings.panel_layout)) {
|
|
registerPanel(panelId, panelState);
|
|
}
|
|
|
|
setupDragHandlers();
|
|
setupWindowShadeHandlers();
|
|
setupLockHandlers();
|
|
}
|
|
|
|
// Register a panel with the dock system
|
|
function registerPanel(panelId: string, panelState: PanelState): void {
|
|
state.panels.set(panelId, panelState);
|
|
applyPanelState(panelId, panelState);
|
|
}
|
|
|
|
// Apply panel state to DOM
|
|
function applyPanelState(panelId: string, panelState: PanelState): void {
|
|
const panel = document.querySelector(`[data-panel="${panelId}"]`);
|
|
if (!panel) return;
|
|
|
|
const container = panel.parentElement;
|
|
if (!container) return;
|
|
|
|
// Apply side positioning
|
|
if (panelState.side === 'left') {
|
|
container.style.left = '0';
|
|
container.style.right = 'auto';
|
|
} else if (panelState.side === 'right') {
|
|
container.style.right = '0';
|
|
container.style.left = 'auto';
|
|
} else {
|
|
container.style.left = '-9999px';
|
|
}
|
|
|
|
// Apply width
|
|
panel.style.width = `${panelState.width_px}px`;
|
|
|
|
// Apply collapsed (window-shade) state
|
|
if (panelState.collapsed) {
|
|
panel.classList.add('panel-collapsed');
|
|
panel.querySelector('.panel-content')?.classList.add('hidden');
|
|
} else {
|
|
panel.classList.remove('panel-collapsed');
|
|
panel.querySelector('.panel-content')?.classList.remove('hidden');
|
|
}
|
|
|
|
// Apply lock state
|
|
const lockBtn = panel.querySelector('.panel-lock');
|
|
if (lockBtn) {
|
|
lockBtn.textContent = panelState.locked ? '🔒' : '🔓';
|
|
}
|
|
}
|
|
|
|
// Setup mouse/touch drag handlers
|
|
function setupDragHandlers(): void {
|
|
document.querySelectorAll('.dockable-panel .panel-header').forEach(header => {
|
|
header.addEventListener('mousedown', handleDragStart);
|
|
header.addEventListener('touchstart', handleDragStart, { passive: false });
|
|
});
|
|
|
|
document.addEventListener('mousemove', handleDragMove);
|
|
document.addEventListener('touchmove', handleDragMove, { passive: false });
|
|
document.addEventListener('mouseup', handleDragEnd);
|
|
document.addEventListener('touchend', handleDragEnd);
|
|
}
|
|
|
|
function handleDragStart(e: MouseEvent | TouchEvent): void {
|
|
const header = e.target.closest('.panel-header') as HTMLElement;
|
|
const panel = header?.closest('.dockable-panel') as HTMLElement;
|
|
if (!panel) return;
|
|
|
|
const panelId = panel.dataset.panel;
|
|
const panelState = state.panels.get(panelId);
|
|
|
|
// Check if locked
|
|
if (panelState?.locked) return;
|
|
|
|
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
|
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
|
|
|
state.dragState = {
|
|
panelId: panelId!,
|
|
startX: clientX,
|
|
startY: clientY,
|
|
currentX: clientX,
|
|
currentY: clientY,
|
|
isLocked: panelState?.locked || false
|
|
};
|
|
|
|
panel.classList.add('dragging');
|
|
}
|
|
|
|
function handleDragMove(e: MouseEvent | TouchEvent): void {
|
|
if (!state.dragState) return;
|
|
|
|
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
|
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
|
|
|
state.dragState.currentX = clientX;
|
|
state.dragState.currentY = clientY;
|
|
|
|
const panel = document.querySelector(`[data-panel="${state.dragState.panelId}"]`);
|
|
const container = panel?.parentElement;
|
|
if (container) {
|
|
container.style.transform = `translateX(${clientX - state.dragState.startX}px)`;
|
|
}
|
|
}
|
|
|
|
function handleDragEnd(e: MouseEvent | TouchEvent): void {
|
|
if (!state.dragState) return;
|
|
|
|
const { panelId, currentX } = state.dragState;
|
|
const panel = document.querySelector(`[data-panel="${panelId}"]`);
|
|
const container = panel?.parentElement;
|
|
|
|
// Reset transform
|
|
container.style.transform = '';
|
|
panel?.classList.remove('dragging');
|
|
|
|
// Determine drop zone
|
|
const newSide = currentX < window.innerWidth / 2 ? 'left' : 'right';
|
|
const panelState = state.panels.get(panelId);
|
|
|
|
if (panelState) {
|
|
// Check if dropped in valid zone
|
|
const isValidDrop = newSide === 'left' || newSide === 'right';
|
|
|
|
if (isValidDrop) {
|
|
panelState.last_valid_side = panelState.side; // Save previous valid position
|
|
panelState.side = newSide;
|
|
} else {
|
|
// Snap back to last valid position
|
|
panelState.side = panelState.last_valid_side;
|
|
}
|
|
|
|
applyPanelState(panelId, panelState);
|
|
savePanelState(panelId, panelState);
|
|
}
|
|
|
|
state.dragState = null;
|
|
}
|
|
|
|
// Setup window-shade (collapse/expand) handlers
|
|
function setupWindowShadeHandlers(): void {
|
|
document.querySelectorAll('.window-shade-toggle').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const panel = (e.target as HTMLElement).closest('.dockable-panel') as HTMLElement;
|
|
const panelId = panel.dataset.panel;
|
|
const panelState = state.panels.get(panelId);
|
|
|
|
if (panelState) {
|
|
panelState.collapsed = !panelState.collapsed;
|
|
applyPanelState(panelId, panelState);
|
|
savePanelState(panelId, panelState);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Setup lock toggle handlers
|
|
function setupLockHandlers(): void {
|
|
document.querySelectorAll('.panel-lock').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const panel = (e.target as HTMLElement).closest('.dockable-panel') as HTMLElement;
|
|
const panelId = panel.dataset.panel;
|
|
const panelState = state.panels.get(panelId);
|
|
|
|
if (panelState) {
|
|
panelState.locked = !panelState.locked;
|
|
applyPanelState(panelId, panelState);
|
|
savePanelState(panelId, panelState);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Persist panel state to settings
|
|
async function savePanelState(panelId: string, panelState: PanelState): Promise<void> {
|
|
const settings = loadSettings();
|
|
settings.panel_layout[panelId as keyof typeof settings.panel_layout] = panelState;
|
|
await saveSettings(settings);
|
|
}
|
|
|
|
export { initializePanelDockSystem, registerPanel, applyPanelState };
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Ebook Reader Implementation
|
|
|
|
### 5.1 Parser Manager (Procedural)
|
|
|
|
**File:** `web/src/reader/parser-manager.ts`
|
|
|
|
```typescript
|
|
// Parser Manager - Routes files to appropriate parsers
|
|
// Procedural style: Functions, not classes
|
|
|
|
import JSZip from 'jszip';
|
|
|
|
// ============================================================
|
|
// Parser Registry
|
|
// ============================================================
|
|
|
|
const PARSER_REGISTRY: ParserEntry[] = [
|
|
{ format: 'epub', mimeType: 'application/epub+zip', extensions: ['.epub'], side: 'client' },
|
|
{ format: 'fb2', mimeType: 'application/fb2', extensions: ['.fb2', '.fb2.zip'], side: 'client' },
|
|
{ format: 'txt', mimeType: 'text/plain', extensions: ['.txt'], side: 'client' },
|
|
{ format: 'html', mimeType: 'text/html', extensions: ['.html', '.htm'], side: 'client' },
|
|
{ format: 'mobi', mimeType: 'application/x-mobipocket-ebook', extensions: ['.mobi', '.azw'], side: 'server' },
|
|
{ format: 'azw3', mimeType: 'application/vnd.amazon.mobi8-ebook', extensions: ['.azw3'], side: 'server' },
|
|
{ format: 'docx', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', extensions: ['.docx'], side: 'server' },
|
|
{ format: 'rtf', mimeType: 'application/rtf', extensions: ['.rtf'], side: 'server' },
|
|
];
|
|
|
|
interface ParserEntry {
|
|
format: string;
|
|
mimeType: string;
|
|
extensions: string[];
|
|
side: 'client' | 'server';
|
|
}
|
|
|
|
// ============================================================
|
|
// Parser Detection
|
|
// ============================================================
|
|
|
|
export function detectParserFormat(mimeType: string, extension: string): ParserEntry | null {
|
|
return PARSER_REGISTRY.find(entry =>
|
|
entry.mimeType === mimeType ||
|
|
entry.extensions.includes(extension.toLowerCase())
|
|
) || null;
|
|
}
|
|
|
|
export function requiresServerParsing(mimeType: string, extension: string): boolean {
|
|
const entry = detectParserFormat(mimeType, extension);
|
|
return entry?.side === 'server' || false;
|
|
}
|
|
|
|
// ============================================================
|
|
// Main Parse Function (Router)
|
|
// ============================================================
|
|
|
|
export async function parseEbook(file: Blob, mimeType: string, extension: string): Promise<EbookCIF> {
|
|
const entry = detectParserFormat(mimeType, extension);
|
|
|
|
if (!entry) {
|
|
throw new Error(`Unsupported ebook format: ${mimeType}, ${extension}`);
|
|
}
|
|
|
|
if (entry.side === 'server') {
|
|
return parseEbookOnServer(file, entry.format);
|
|
} else {
|
|
return parseEbookOnClient(file, entry.format);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Client-Side Parsing
|
|
// ============================================================
|
|
|
|
async function parseEbookOnClient(file: Blob, format: string): Promise<EbookCIF> {
|
|
switch (format) {
|
|
case 'epub':
|
|
return parseEPUB(file);
|
|
case 'fb2':
|
|
return parseFB2(file);
|
|
case 'txt':
|
|
return parseTXT(file);
|
|
case 'html':
|
|
return parseHTML(file);
|
|
default:
|
|
throw new Error(`Client-side parser not implemented for: ${format}`);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Server-Side Parsing (API Call)
|
|
// ============================================================
|
|
|
|
async function parseEbookOnServer(file: Blob, format: string): Promise<EbookCIF> {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('format', format);
|
|
|
|
const response = await fetch('/readers/parse', {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Server parsing failed: ${response.statusText}`);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 5.2 EPUB Parser (Refactored - Procedural)
|
|
|
|
**File:** `web/src/reader/parsers/epub-parser.ts`
|
|
|
|
```typescript
|
|
// EPUB Parser - Converts EPUB 2/3 to Common Intermediate Format
|
|
// Procedural style: Functions, not classes
|
|
|
|
import JSZip from 'jszip';
|
|
|
|
// ============================================================
|
|
// Main Parse Function
|
|
// ============================================================
|
|
|
|
export async function parseEPUB(epubBlob: Blob): Promise<EbookCIF> {
|
|
const zip = await JSZip.loadAsync(epubBlob);
|
|
|
|
// Parse container.xml to find OPF file
|
|
const containerXml = await getZipFileContent(zip, 'META-INF/container.xml');
|
|
const opfPath = extractOPFPath(containerXml);
|
|
|
|
if (!opfPath) {
|
|
throw new Error('Invalid EPUB: no OPF file found');
|
|
}
|
|
|
|
// Parse OPF file
|
|
const opfXml = await getZipFileContent(zip, opfPath);
|
|
const packageDoc = parseXML(opfXml);
|
|
|
|
// Extract all components
|
|
const metadata = extractMetadata(packageDoc);
|
|
const spine = parseSpine(packageDoc);
|
|
const toc = await parseTOC(zip, packageDoc, opfPath);
|
|
const resources = await loadResources(zip);
|
|
const coverImage = await extractCover(zip, packageDoc);
|
|
|
|
// Calculate locations (minimal - backend handles detailed tracking)
|
|
const totalCharacters = await calculateTotalCharacters(spine, resources);
|
|
|
|
return {
|
|
metadata,
|
|
toc,
|
|
spine,
|
|
resources,
|
|
locations: {
|
|
totalCharacters,
|
|
estimatedPages: Math.ceil(totalCharacters / 1500),
|
|
},
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// Helper Functions
|
|
// ============================================================
|
|
|
|
async function getZipFileContent(zip: JSZip, path: string): Promise<string> {
|
|
const file = zip.file(path);
|
|
if (!file) {
|
|
throw new Error(`File not found: ${path}`);
|
|
}
|
|
return await file.async('text');
|
|
}
|
|
|
|
function parseXML(xmlString: string): XMLDocument {
|
|
const parser = new DOMParser();
|
|
return parser.parseFromString(xmlString, 'text/xml');
|
|
}
|
|
|
|
function extractOPFPath(containerXml: string): string | null {
|
|
const containerDoc = parseXML(containerXml);
|
|
return containerDoc.querySelector('rootfile')?.getAttribute('full-path') || null;
|
|
}
|
|
|
|
function extractMetadata(packageDoc: XMLDocument): EbookCIF['metadata'] {
|
|
const metadata = packageDoc.querySelector('metadata');
|
|
if (!metadata) {
|
|
throw new Error('No metadata found in OPF');
|
|
}
|
|
|
|
return {
|
|
title: metadata.querySelector('title')?.textContent || '',
|
|
author: metadata.querySelector('creator')?.textContent || '',
|
|
language: metadata.querySelector('language')?.textContent || 'en',
|
|
publisher: metadata.querySelector('publisher')?.textContent || undefined,
|
|
isbn: metadata.querySelector('identifier')?.textContent || undefined,
|
|
};
|
|
}
|
|
|
|
function parseSpine(packageDoc: XMLDocument): EbookCIF['spine'] {
|
|
const spine = packageDoc.querySelector('spine');
|
|
const manifest = packageDoc.querySelector('manifest');
|
|
|
|
if (!spine || !manifest) {
|
|
throw new Error('No spine or manifest found in OPF');
|
|
}
|
|
|
|
const spineItems = spine.querySelectorAll('itemref');
|
|
const result: EbookCIF['spine'] = [];
|
|
|
|
spineItems.forEach((itemref) => {
|
|
const idref = itemref.getAttribute('idref');
|
|
if (!idref) return;
|
|
|
|
const manifestItem = manifest.querySelector(`[id="${idref}"]`);
|
|
if (!manifestItem) return;
|
|
|
|
const href = manifestItem.getAttribute('href');
|
|
if (!href) return;
|
|
|
|
result.push({
|
|
id: idref,
|
|
type: 'html',
|
|
content: href,
|
|
properties: itemref.getAttribute('properties') || undefined,
|
|
});
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
async function parseTOC(zip: JSZip, packageDoc: XMLDocument, opfPath: string): Promise<EbookCIF['toc']> {
|
|
// Try EPUB 3.0 navigation document first
|
|
const navItem = packageDoc.querySelector('manifest item[properties~="nav"]');
|
|
if (navItem) {
|
|
const navHref = navItem.getAttribute('href');
|
|
if (navHref) {
|
|
const navPath = resolvePath(opfPath, navHref);
|
|
return parseNavTOC(zip, navPath);
|
|
}
|
|
}
|
|
|
|
// Fallback to EPUB 2.0 NCX
|
|
const ncxId = spine?.getAttribute('toc');
|
|
if (ncxId) {
|
|
const ncxItem = packageDoc.querySelector(`manifest [id="${ncxId}"]`);
|
|
if (ncxItem) {
|
|
const ncxHref = ncxItem.getAttribute('href');
|
|
if (ncxHref) {
|
|
const ncxPath = resolvePath(opfPath, ncxHref);
|
|
return parseNCXTOC(zip, ncxPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
async function parseNavTOC(zip: JSZip, navPath: string): Promise<EbookCIF['toc']> {
|
|
const navXml = await getZipFileContent(zip, navPath);
|
|
const navDoc = parseXML(navXml);
|
|
const nav = navDoc.querySelector('nav');
|
|
|
|
if (!nav) return [];
|
|
|
|
const ol = nav.querySelector('ol');
|
|
if (!ol) return [];
|
|
|
|
const items = ol.querySelectorAll(':scope > li');
|
|
const result: EbookCIF['toc'] = [];
|
|
|
|
for (const li of items) {
|
|
const link = li.querySelector('a');
|
|
if (link) {
|
|
result.push({
|
|
id: link.getAttribute('href') || '',
|
|
title: link.textContent || '',
|
|
href: link.getAttribute('href') || '',
|
|
children: [],
|
|
});
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function parseNCXTOC(zip: JSZip, ncxPath: string): Promise<EbookCIF['toc']> {
|
|
const ncxXml = await getZipFileContent(zip, ncxPath);
|
|
const ncxDoc = parseXML(ncxXml);
|
|
const navMap = ncxDoc.querySelector('navMap');
|
|
|
|
if (!navMap) return [];
|
|
|
|
return parseNCXNode(navMap);
|
|
}
|
|
|
|
function parseNCXNode(node: Element): EbookCIF['toc'] {
|
|
const navPoints = node.querySelectorAll(':scope > navPoint');
|
|
const result: EbookCIF['toc'] = [];
|
|
|
|
navPoints.forEach((navPoint) => {
|
|
const label = navPoint.querySelector('navLabel text')?.textContent || '';
|
|
const content = navPoint.querySelector('content');
|
|
const href = content?.getAttribute('src') || '';
|
|
|
|
result.push({
|
|
id: href,
|
|
title: label,
|
|
href,
|
|
children: parseNCXNode(navPoint),
|
|
});
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
async function loadResources(zip: JSZip): Promise<Map<string, Blob>> {
|
|
const resources = new Map<string, Blob>();
|
|
const files = Object.keys(zip.files);
|
|
|
|
for (const path of files) {
|
|
const file = zip.file(path);
|
|
if (file && !file.dir) {
|
|
const blob = await file.async('blob');
|
|
resources.set(path, blob);
|
|
}
|
|
}
|
|
|
|
return resources;
|
|
}
|
|
|
|
async function extractCover(zip: JSZip, packageDoc: XMLDocument): Promise<Blob | undefined> {
|
|
// Try cover-id metadata
|
|
const coverId = packageDoc.querySelector('meta[name="cover"]')?.getAttribute('content');
|
|
if (coverId) {
|
|
const coverItem = packageDoc.querySelector(`manifest [id="${coverId}"]`);
|
|
if (coverItem) {
|
|
const coverHref = coverItem.getAttribute('href');
|
|
if (coverHref) {
|
|
const coverFile = zip.file(coverHref);
|
|
if (coverFile) {
|
|
return await coverFile.async('blob');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: look for cover image in manifest
|
|
const coverItem = packageDoc.querySelector('manifest item[properties~="cover-image"]');
|
|
if (coverItem) {
|
|
const coverHref = coverItem.getAttribute('href');
|
|
if (coverHref) {
|
|
const coverFile = zip.file(coverHref);
|
|
if (coverFile) {
|
|
return await coverFile.async('blob');
|
|
}
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function resolvePath(basePath: string, relativePath: string): string {
|
|
const baseDir = basePath.substring(0, basePath.lastIndexOf('/') + 1);
|
|
return baseDir + relativePath;
|
|
}
|
|
|
|
async function calculateTotalCharacters(spine: EbookCIF['spine'], resources: Map<string, Blob>): Promise<number> {
|
|
let total = 0;
|
|
|
|
for (const item of spine) {
|
|
if (item.type === 'html') {
|
|
const content = resources.get(item.content);
|
|
if (content) {
|
|
const text = await content.text();
|
|
total += text.length;
|
|
}
|
|
}
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
function resolvePath(basePath: string, relativePath: string): string {
|
|
const baseDir = basePath.substring(0, basePath.lastIndexOf('/') + 1);
|
|
return baseDir + relativePath;
|
|
}
|
|
}
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
function generatePageBreaks(totalCharacters: number): number[] {
|
|
const breaks: number[] = [];
|
|
const charsPerPage = 1000; // Rough estimate
|
|
|
|
for (let i = charsPerPage; i < totalCharacters; i += charsPerPage) {
|
|
breaks.push(i);
|
|
}
|
|
|
|
return breaks;
|
|
}
|
|
|
|
// ============================================================
|
|
// Metadata Quick Extract (for library view)
|
|
// ============================================================
|
|
|
|
export async function extractEPUBMetadata(epubBlob: Blob): Promise<Partial<EbookCIF['metadata']>> {
|
|
const zip = await JSZip.loadAsync(epubBlob);
|
|
|
|
const containerXml = await getZipFileContent(zip, 'META-INF/container.xml');
|
|
const opfPath = extractOPFPath(containerXml);
|
|
|
|
if (!opfPath) {
|
|
return {};
|
|
}
|
|
|
|
const opfXml = await getZipFileContent(zip, opfPath);
|
|
const packageDoc = parseXML(opfXml);
|
|
|
|
return extractMetadata(packageDoc);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 5.3 FictionBook 2 (FB2) Parser
|
|
|
|
**File:** `web/src/reader/parsers/fb2-parser.ts`
|
|
|
|
```typescript
|
|
// FB2 Parser - Converts FictionBook 2 to Common Intermediate Format
|
|
// FB2 is XML-based, similar to EPUB structure
|
|
// Procedural style: Functions, not classes
|
|
|
|
import JSZip from 'jszip';
|
|
|
|
// ============================================================
|
|
// Main Parse Function
|
|
// ============================================================
|
|
|
|
export async function parseFB2(fb2Blob: Blob): Promise<EbookCIF> {
|
|
// FB2 can be plain XML or zipped (.fb2.zip)
|
|
let xmlContent: string;
|
|
|
|
if (fb2Blob.type === 'application/zip' || fb2Blob.type === 'application/x-zip-compressed') {
|
|
const zip = await JSZip.loadAsync(fb2Blob);
|
|
const files = Object.keys(zip.files);
|
|
|
|
// Find the first .fb2 file in the zip
|
|
const fb2File = files.find(f => f.endsWith('.fb2'));
|
|
if (!fb2File) {
|
|
throw new Error('No .fb2 file found in archive');
|
|
}
|
|
|
|
xmlContent = await zip.file(fb2File)!.async('text');
|
|
} else {
|
|
xmlContent = await fb2Blob.text();
|
|
}
|
|
|
|
const xmlDoc = parseXML(xmlContent);
|
|
|
|
const metadata = extractFB2Metadata(xmlDoc);
|
|
const toc = parseFB2TOC(xmlDoc);
|
|
const spine = createFB2Spine(xmlDoc);
|
|
const resources = await extractFB2Resources(xmlDoc, fb2Blob);
|
|
|
|
// Calculate locations (minimal - backend handles detailed tracking)
|
|
const totalCharacters = calculateFB2Characters(xmlDoc);
|
|
|
|
return {
|
|
metadata,
|
|
toc,
|
|
spine,
|
|
resources,
|
|
locations: {
|
|
totalCharacters,
|
|
estimatedPages: Math.ceil(totalCharacters / 1500),
|
|
},
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// Helper Functions
|
|
// ============================================================
|
|
|
|
function parseXML(xmlString: string): XMLDocument {
|
|
const parser = new DOMParser();
|
|
return parser.parseFromString(xmlString, 'text/xml');
|
|
}
|
|
|
|
function extractFB2Metadata(xmlDoc: XMLDocument): EbookCIF['metadata'] {
|
|
const titleInfo = xmlDoc.querySelector('title-info');
|
|
const documentInfo = xmlDoc.querySelector('document-info');
|
|
|
|
if (!titleInfo) {
|
|
throw new Error('Invalid FB2: no title-info found');
|
|
}
|
|
|
|
return {
|
|
title: titleInfo.querySelector('book-title')?.textContent || '',
|
|
author: extractFB2Author(titleInfo),
|
|
language: titleInfo.querySelector('lang')?.textContent || 'en',
|
|
publisher: documentInfo?.querySelector('publisher')?.textContent || undefined,
|
|
isbn: undefined, // FB2 doesn't typically have ISBN
|
|
};
|
|
}
|
|
|
|
function extractFB2Author(titleInfo: Element): string {
|
|
const author = titleInfo.querySelector('author');
|
|
if (!author) return '';
|
|
|
|
const firstName = author.querySelector('first-name')?.textContent || '';
|
|
const lastName = author.querySelector('last-name')?.textContent || '';
|
|
const middleName = author.querySelector('middle-name')?.textContent || '';
|
|
|
|
const parts = [firstName, middleName, lastName].filter(Boolean);
|
|
return parts.join(' ') || 'Unknown';
|
|
}
|
|
|
|
function parseFB2TOC(xmlDoc: XMLDocument): EbookCIF['toc'] {
|
|
const toc: EbookCIF['toc'] = [];
|
|
const body = xmlDoc.querySelector('body');
|
|
|
|
if (!body) return toc;
|
|
|
|
const sections = body.querySelectorAll(':scope > section');
|
|
let sectionIndex = 0;
|
|
|
|
for (const section of sections) {
|
|
const title = section.querySelector('title');
|
|
const titleText = title?.textContent.trim() || `Section ${sectionIndex + 1}`;
|
|
|
|
toc.push({
|
|
id: `section-${sectionIndex}`,
|
|
title: titleText,
|
|
href: `#section-${sectionIndex}`,
|
|
children: [],
|
|
});
|
|
|
|
sectionIndex++;
|
|
}
|
|
|
|
return toc;
|
|
}
|
|
|
|
function createFB2Spine(xmlDoc: XMLDocument): EbookCIF['spine'] {
|
|
const spine: EbookCIF['spine'] = [];
|
|
const body = xmlDoc.querySelector('body');
|
|
|
|
if (!body) return spine;
|
|
|
|
// Convert each section to HTML
|
|
const sections = body.querySelectorAll(':scope > section');
|
|
|
|
sections.forEach((section, index) => {
|
|
const htmlContent = convertFB2SectionToHTML(section, index);
|
|
|
|
spine.push({
|
|
id: `section-${index}`,
|
|
type: 'html',
|
|
content: htmlContent,
|
|
index,
|
|
});
|
|
});
|
|
|
|
return spine;
|
|
}
|
|
|
|
function convertFB2SectionToHTML(section: Element, index: number): string {
|
|
const title = section.querySelector('title');
|
|
let html = `<div id="section-${index}" class="fb2-section">`;
|
|
|
|
if (title) {
|
|
html += `<h1>${title.textContent}</h1>`;
|
|
}
|
|
|
|
// Convert paragraphs
|
|
const paragraphs = section.querySelectorAll('p');
|
|
paragraphs.forEach(p => {
|
|
html += `<p>${p.innerHTML}</p>`;
|
|
});
|
|
|
|
// Convert images
|
|
const images = section.querySelectorAll('image');
|
|
images.forEach(img => {
|
|
const href = img.getAttribute('l:href');
|
|
const alt = img.getAttribute('alt') || '';
|
|
if (href) {
|
|
html += `<img src="${href}" alt="${alt}" />`;
|
|
}
|
|
});
|
|
|
|
html += '</div>';
|
|
|
|
return html;
|
|
}
|
|
|
|
async function extractFB2Resources(xmlDoc: XMLDocument, fb2Blob: Blob): Promise<Map<string, Blob>> {
|
|
const resources = new Map<string, Blob>();
|
|
|
|
// FB2 can have embedded images (base64) or external references
|
|
const binary = xmlDoc.querySelector('binary');
|
|
if (binary) {
|
|
const contentType = binary.getAttribute('content-type');
|
|
const id = binary.getAttribute('id');
|
|
|
|
if (contentType && id && binary.textContent) {
|
|
// Decode base64
|
|
const base64Data = binary.textContent.trim();
|
|
const byteString = atob(base64Data);
|
|
const byteArray = new Uint8Array(byteString.length);
|
|
|
|
for (let i = 0; i < byteString.length; i++) {
|
|
byteArray[i] = byteString.charCodeAt(i);
|
|
}
|
|
|
|
const blob = new Blob([byteArray], { type: contentType });
|
|
resources.set(`#${id}`, blob);
|
|
}
|
|
}
|
|
|
|
return resources;
|
|
}
|
|
|
|
function calculateFB2Characters(xmlDoc: XMLDocument): number {
|
|
const body = xmlDoc.querySelector('body');
|
|
if (!body) return 0;
|
|
|
|
return body.textContent?.length || 0;
|
|
}
|
|
|
|
function generatePageBreaks(totalCharacters: number): number[] {
|
|
const breaks: number[] = [];
|
|
const charsPerPage = 1000;
|
|
|
|
for (let i = charsPerPage; i < totalCharacters; i += charsPerPage) {
|
|
breaks.push(i);
|
|
}
|
|
|
|
return breaks;
|
|
}
|
|
|
|
// ============================================================
|
|
// Metadata Quick Extract
|
|
// ============================================================
|
|
|
|
export async function extractFB2Metadata(fb2Blob: Blob): Promise<Partial<EbookCIF['metadata']>> {
|
|
let xmlContent: string;
|
|
|
|
if (fb2Blob.type === 'application/zip') {
|
|
const zip = await JSZip.loadAsync(fb2Blob);
|
|
const files = Object.keys(zip.files);
|
|
const fb2File = files.find(f => f.endsWith('.fb2'));
|
|
|
|
if (!fb2File) return {};
|
|
|
|
xmlContent = await zip.file(fb2File)!.async('text');
|
|
} else {
|
|
xmlContent = await fb2Blob.text();
|
|
}
|
|
|
|
const xmlDoc = parseXML(xmlContent);
|
|
return extractFB2Metadata(xmlDoc);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 5.4 Plain Text (TXT) Parser
|
|
|
|
**File:** `web/src/reader/parsers/txt-parser.ts`
|
|
|
|
```typescript
|
|
// TXT Parser - Wraps plain text in HTML structure
|
|
// Procedural style: Functions, not classes
|
|
|
|
// ============================================================
|
|
// Main Parse Function
|
|
// ============================================================
|
|
|
|
export async function parseTXT(txtBlob: Blob): Promise<EbookCIF> {
|
|
const textContent = await txtBlob.text();
|
|
|
|
const metadata = extractTXTMetadata(txtBlob);
|
|
const toc = createTXTTOC(textContent);
|
|
const spine = createTXTSpine(textContent);
|
|
const resources = new Map(); // No external resources for plain text
|
|
|
|
const totalCharacters = textContent.length;
|
|
|
|
return {
|
|
metadata,
|
|
toc,
|
|
spine,
|
|
resources,
|
|
locations: {
|
|
totalCharacters,
|
|
estimatedPages: Math.ceil(totalCharacters / 1500),
|
|
},
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// Helper Functions
|
|
// ============================================================
|
|
|
|
function extractTXTMetadata(txtBlob: Blob): EbookCIF['metadata'] {
|
|
const filename = txtBlob.name || 'Unknown';
|
|
|
|
return {
|
|
title: filename.replace(/\.(txt|text)$/i, ''),
|
|
author: 'Unknown',
|
|
language: 'en',
|
|
};
|
|
}
|
|
|
|
function createTXTTOC(textContent: string): EbookCIF['toc'] {
|
|
// Try to detect chapters (simple heuristic)
|
|
const toc: EbookCIF['toc'] = [];
|
|
const lines = textContent.split('\n');
|
|
|
|
let chapterIndex = 0;
|
|
|
|
lines.forEach((line, index) => {
|
|
// Common chapter patterns
|
|
const chapterPattern = /^(chapter|part|section)\s+\d+/i;
|
|
if (chapterPattern.test(line.trim())) {
|
|
toc.push({
|
|
id: `chapter-${chapterIndex}`,
|
|
title: line.trim(),
|
|
href: `#chapter-${chapterIndex}`,
|
|
children: [],
|
|
});
|
|
|
|
chapterIndex++;
|
|
}
|
|
});
|
|
|
|
// If no chapters found, create single entry
|
|
if (toc.length === 0) {
|
|
toc.push({
|
|
id: 'full-text',
|
|
title: 'Full Text',
|
|
href: '#full-text',
|
|
children: [],
|
|
});
|
|
}
|
|
|
|
return toc;
|
|
}
|
|
|
|
function createTXTSpine(textContent: string): EbookCIF['spine'] {
|
|
// Convert plain text to HTML paragraphs
|
|
const lines = textContent.split('\n');
|
|
let htmlContent = '<div class="txt-content">';
|
|
|
|
lines.forEach(line => {
|
|
const trimmed = line.trim();
|
|
if (trimmed) {
|
|
htmlContent += `<p>${escapeHTML(trimmed)}</p>`;
|
|
} else {
|
|
htmlContent += '<br />';
|
|
}
|
|
});
|
|
|
|
htmlContent += '</div>';
|
|
|
|
return [{
|
|
id: 'full-text',
|
|
type: 'html',
|
|
content: htmlContent,
|
|
index: 0,
|
|
}];
|
|
}
|
|
|
|
function escapeHTML(text: string): string {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Removed - backend handles detailed position tracking
|
|
|
|
// ============================================================
|
|
// Metadata Quick Extract
|
|
// ============================================================
|
|
|
|
export async function extractTXTMetadata(txtBlob: Blob): Promise<Partial<EbookCIF['metadata']>> {
|
|
return extractTXTMetadata(txtBlob);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 5.5 HTML Parser
|
|
|
|
**File:** `web/src/reader/parsers/html-parser.ts`
|
|
|
|
```typescript
|
|
// HTML Parser - Wraps standalone HTML files
|
|
// Procedural style: Functions, not classes
|
|
|
|
// ============================================================
|
|
// Main Parse Function
|
|
// ============================================================
|
|
|
|
export async function parseHTML(htmlBlob: Blob): Promise<EbookCIF> {
|
|
const htmlContent = await htmlBlob.text();
|
|
|
|
const metadata = extractHTMLMetadata(htmlBlob, htmlContent);
|
|
const toc = createHTMLTOC(htmlContent);
|
|
const spine = createHTMLSpine(htmlContent);
|
|
const resources = await extractHTMLResources(htmlBlob, htmlContent);
|
|
|
|
const totalCharacters = stripHTML(htmlContent).length;
|
|
const pageBreaks = generatePageBreaks(totalCharacters);
|
|
|
|
return {
|
|
metadata,
|
|
toc,
|
|
spine,
|
|
resources,
|
|
locations: {
|
|
totalCharacters,
|
|
pageBreaks,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// Helper Functions
|
|
// ============================================================
|
|
|
|
function extractHTMLMetadata(htmlBlob: Blob, htmlContent: string): EbookCIF['metadata'] {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(htmlContent, 'text/html');
|
|
|
|
const title = doc.querySelector('title')?.textContent ||
|
|
htmlBlob.name.replace(/\.(html?|htm)$/i, '');
|
|
|
|
const metaAuthor = doc.querySelector('meta[name="author"]')?.getAttribute('content');
|
|
const metaLang = doc.querySelector('html')?.getAttribute('lang') || 'en';
|
|
|
|
return {
|
|
title,
|
|
author: metaAuthor || 'Unknown',
|
|
language: metaLang,
|
|
};
|
|
}
|
|
|
|
function createHTMLTOC(htmlContent: string): EbookCIF['toc'] {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(htmlContent, 'text/html');
|
|
|
|
const toc: EbookCIF['toc'] = [];
|
|
|
|
// Try to find headings
|
|
const headings = doc.querySelectorAll('h1, h2, h3');
|
|
let headingIndex = 0;
|
|
|
|
headings.forEach(heading => {
|
|
toc.push({
|
|
id: `heading-${headingIndex}`,
|
|
title: heading.textContent || '',
|
|
href: `#${heading.id || `heading-${headingIndex}`}`,
|
|
children: [],
|
|
});
|
|
|
|
headingIndex++;
|
|
});
|
|
|
|
// If no headings, create single entry
|
|
if (toc.length === 0) {
|
|
toc.push({
|
|
id: 'full-document',
|
|
title: 'Full Document',
|
|
href: '#full-document',
|
|
children: [],
|
|
});
|
|
}
|
|
|
|
return toc;
|
|
}
|
|
|
|
function createHTMLSpine(htmlContent: string): EbookCIF['spine'] {
|
|
return [{
|
|
id: 'full-document',
|
|
type: 'html',
|
|
content: htmlContent,
|
|
index: 0,
|
|
}];
|
|
}
|
|
|
|
async function extractHTMLResources(htmlBlob: Blob, htmlContent: string): Promise<Map<string, Blob>> {
|
|
const resources = new Map<string, Blob>();
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(htmlContent, 'text/html');
|
|
|
|
// Extract images
|
|
const images = doc.querySelectorAll('img[src]');
|
|
|
|
for (const img of Array.from(images)) {
|
|
const src = img.getAttribute('src');
|
|
if (!src) continue;
|
|
|
|
// Try to resolve relative URLs
|
|
if (src.startsWith('data:')) {
|
|
// Data URI - extract blob
|
|
const match = src.match(/^data:([^;]+);base64,(.+)$/);
|
|
if (match) {
|
|
const mimeType = match[1];
|
|
const base64 = match[2];
|
|
const byteString = atob(base64);
|
|
const byteArray = new Uint8Array(byteString.length);
|
|
|
|
for (let i = 0; i < byteString.length; i++) {
|
|
byteArray[i] = byteString.charCodeAt(i);
|
|
}
|
|
|
|
const blob = new Blob([byteArray], { type: mimeType });
|
|
resources.set(src, blob);
|
|
}
|
|
}
|
|
// External resources would need to be fetched
|
|
// For now, skip them (browser will load them naturally)
|
|
}
|
|
|
|
return resources;
|
|
}
|
|
|
|
function stripHTML(html: string): string {
|
|
const div = document.createElement('div');
|
|
div.innerHTML = html;
|
|
return div.textContent || '';
|
|
}
|
|
|
|
// ============================================================
|
|
// Metadata Quick Extract
|
|
// ============================================================
|
|
|
|
export async function extractHTMLMetadata(htmlBlob: Blob): Promise<Partial<EbookCIF['metadata']>> {
|
|
const htmlContent = await htmlBlob.text();
|
|
return extractHTMLMetadata(htmlBlob, htmlContent);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 5.6 HTML Renderer (Procedural)
|
|
|
|
**File:** `web/src/reader/ebook/html-renderer.ts`
|
|
|
|
```typescript
|
|
// HTML rendering with theme support, font loading, and image handling
|
|
// Procedural style: Functions, not classes
|
|
|
|
interface RendererConfig {
|
|
readingTheme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast';
|
|
readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex';
|
|
fontSize: number;
|
|
lineHeight: number;
|
|
marginWidth: number;
|
|
textAlign: 'left' | 'justify';
|
|
columnCount: 1 | 2;
|
|
}
|
|
|
|
// ============================================================
|
|
// Main Render Function
|
|
// ============================================================
|
|
|
|
export async function renderHTMLDocument(
|
|
doc: HTMLDocument,
|
|
container: HTMLElement,
|
|
config: RendererConfig
|
|
): Promise<void> {
|
|
// Apply theme
|
|
applyHTMLTheme(container, config.readingTheme);
|
|
|
|
// Apply typography settings
|
|
applyHTMLTypography(container, config);
|
|
|
|
// Inject custom styles for reader
|
|
injectHTMLReaderStyles(container);
|
|
|
|
// Handle embedded fonts
|
|
await loadEmbeddedHTMLFonts(doc, container);
|
|
|
|
// Handle images
|
|
processHTMLImages(doc, container);
|
|
|
|
// Clear container and append content
|
|
container.innerHTML = '';
|
|
container.appendChild(doc.body);
|
|
|
|
// Apply column layout
|
|
applyHTMLColumnLayout(container, config.columnCount);
|
|
}
|
|
|
|
// ============================================================
|
|
// Theme Application
|
|
// ============================================================
|
|
|
|
function applyHTMLTheme(container: HTMLElement, theme: string): void {
|
|
const readingThemes: Record<string, Record<string, string>> = {
|
|
'light': {
|
|
'--bg-primary': '#ffffff',
|
|
'--text-primary': '#1a1a1a',
|
|
'--text-secondary': '#666666',
|
|
'--accent': '#0066cc'
|
|
},
|
|
'sepia': {
|
|
'--bg-primary': '#f4ecd8',
|
|
'--text-primary': '#5f4b32',
|
|
'--text-secondary': '#8b7355',
|
|
'--accent': '#8b4513'
|
|
},
|
|
'dark': {
|
|
'--bg-primary': '#1a1b26',
|
|
'--text-primary': '#c0caf5',
|
|
'--text-secondary': '#565f89',
|
|
'--accent': '#7aa2f7'
|
|
},
|
|
'night': {
|
|
'--bg-primary': '#0d1117',
|
|
'--text-primary': '#c9d1d9',
|
|
'--text-secondary': '#8b949e',
|
|
'--accent': '#58a6ff'
|
|
},
|
|
'high-contrast': {
|
|
'--bg-primary': '#000000',
|
|
'--text-primary': '#ffffff',
|
|
'--text-secondary': '#cccccc',
|
|
'--accent': '#ffff00'
|
|
}
|
|
};
|
|
|
|
const themeConfig = readingThemes[theme] || readingThemes['dark'];
|
|
|
|
for (const [key, value] of Object.entries(themeConfig)) {
|
|
container.style.setProperty(key, value);
|
|
}
|
|
}
|
|
|
|
function applyHTMLTypography(container: HTMLElement, config: RendererConfig): void {
|
|
const style = document.createElement('style');
|
|
const fontStack = getFontStack(config.readingFont);
|
|
|
|
style.textContent = `
|
|
.ebook-content {
|
|
font-family: ${fontStack};
|
|
font-size: ${config.fontSize}px;
|
|
line-height: ${config.lineHeight};
|
|
text-align: ${config.textAlign};
|
|
padding: 0 ${config.marginWidth}px;
|
|
max-width: 100%;
|
|
overflow-wrap: break-word;
|
|
}
|
|
|
|
.ebook-content p {
|
|
margin-bottom: 1em;
|
|
text-indent: ${config.textAlign === 'justify' ? '1.5em' : '0'};
|
|
}
|
|
|
|
.ebook-content img {
|
|
max-width: 100%;
|
|
height: auto;
|
|
display: block;
|
|
margin: 1em auto;
|
|
}
|
|
|
|
.ebook-content a {
|
|
color: var(--accent);
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.ebook-content a:active {
|
|
color: var(--text-secondary);
|
|
}
|
|
`;
|
|
|
|
container.appendChild(style);
|
|
}
|
|
|
|
function injectHTMLReaderStyles(container: HTMLElement): void {
|
|
container.setAttribute('role', 'main');
|
|
container.setAttribute('aria-label', 'Book content');
|
|
}
|
|
|
|
async function loadEmbeddedHTMLFonts(doc: HTMLDocument, container: HTMLElement): Promise<void> {
|
|
const styleSheets = doc.querySelectorAll('style');
|
|
|
|
for (const sheet of styleSheets) {
|
|
const fontFaceRegex = /@font-face\s*{([^}]+)}/g;
|
|
const matches = sheet.textContent?.matchAll(fontFaceRegex) || [];
|
|
|
|
for (const match of matches) {
|
|
const fontFace = match[1];
|
|
const urlMatch = /url\(['"]?([^'")]+)['"]?\)/.exec(fontFace);
|
|
|
|
if (urlMatch) {
|
|
const fontUrl = urlMatch[1];
|
|
await loadHTMLFont(fontUrl, container);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadHTMLFont(fontUrl: string, container: HTMLElement): Promise<void> {
|
|
const loadedFonts = container.dataset.loadedFonts ?
|
|
JSON.parse(container.dataset.loadedFonts) : [];
|
|
|
|
if (loadedFonts.includes(fontUrl)) return;
|
|
|
|
try {
|
|
const fontFace = new FontFace('custom-font', `url(${fontUrl})`);
|
|
await fontFace.load();
|
|
document.fonts.add(fontFace);
|
|
|
|
loadedFonts.push(fontUrl);
|
|
container.dataset.loadedFonts = JSON.stringify(loadedFonts);
|
|
} catch (error) {
|
|
console.error('Failed to load font:', fontUrl, error);
|
|
}
|
|
}
|
|
|
|
function processHTMLImages(doc: HTMLDocument): void {
|
|
const images = doc.querySelectorAll('img');
|
|
|
|
images.forEach((img) => {
|
|
img.setAttribute('loading', 'lazy');
|
|
|
|
if (!img.alt) {
|
|
img.alt = 'Image from book';
|
|
}
|
|
|
|
img.style.cursor = 'pointer';
|
|
img.addEventListener('click', () => {
|
|
showImageFullscreen(img.src);
|
|
});
|
|
});
|
|
}
|
|
|
|
function showImageFullscreen(src: string): void {
|
|
const modal = document.createElement('div');
|
|
modal.className = 'fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50';
|
|
modal.onclick = () => modal.remove();
|
|
|
|
const img = document.createElement('img');
|
|
img.src = src;
|
|
img.className = 'max-w-full max-h-full object-contain';
|
|
|
|
modal.appendChild(img);
|
|
document.body.appendChild(modal);
|
|
}
|
|
|
|
function applyHTMLColumnLayout(container: HTMLElement, columnCount: number): void {
|
|
if (columnCount === 2) {
|
|
container.style.columnCount = '2';
|
|
container.style.columnGap = '20px';
|
|
container.style.columnRule = '1px solid var(--text-secondary)';
|
|
} else {
|
|
container.style.columnCount = 'auto';
|
|
}
|
|
}
|
|
|
|
function getFontStack(font: string): string {
|
|
const stacks: Record<string, string> = {
|
|
'literata': '"Literata", serif',
|
|
'crimson': '"Crimson Text", serif',
|
|
'source-serif': '"Source Serif 4", serif',
|
|
'eb-garamond': '"EB Garamond", serif',
|
|
'libertinus': '"Libertinus Serif", serif',
|
|
'noto-serif': '"Noto Serif", serif',
|
|
'charis-sil': '"Charis SIL", serif',
|
|
'ibm-plex': '"IBM Plex Serif", serif',
|
|
};
|
|
|
|
return stacks[font] || stacks['literata'];
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 5.7 CFI Navigation (Procedural)
|
|
|
|
**File:** `web/src/reader/ebook/cfi-navigator.ts`
|
|
|
|
```typescript
|
|
// EPUB CFI (Canonical Fragment Identifier) navigation
|
|
// Reuses logic from internal/sync/format.go
|
|
// Procedural style: Functions, not classes
|
|
|
|
interface CFIComponent {
|
|
type: 'index' | 'indirection-step' | 'text-location';
|
|
value: number;
|
|
id?: string;
|
|
textOffset?: number;
|
|
}
|
|
|
|
// ============================================================
|
|
// CFI Parsing Functions
|
|
// ============================================================
|
|
|
|
export function parseCFI(cfi: string): CFIComponent[] {
|
|
const components: CFIComponent[] = [];
|
|
|
|
const cleanCFI = cfi.startsWith('!') ? cfi.substring(1) : cfi;
|
|
const parts = cleanCFI.split('/').filter(Boolean);
|
|
|
|
for (const part of parts) {
|
|
const match = part.match(/^(\d+)(?:\[([^\]]+)\])?(?::(\d+))?$/);
|
|
if (match) {
|
|
const component: CFIComponent = {
|
|
type: match[3] !== undefined ? 'text-location' : 'index',
|
|
value: parseInt(match[1], 10),
|
|
id: match[2],
|
|
textOffset: match[3] !== undefined ? parseInt(match[3], 10) : undefined
|
|
};
|
|
|
|
components.push(component);
|
|
}
|
|
}
|
|
|
|
return components;
|
|
}
|
|
|
|
export function generateCFI(
|
|
spineIndex: number,
|
|
elementPath: number[],
|
|
textOffset: number = 0,
|
|
spineItemId?: string
|
|
): string {
|
|
let cfi = `/6/${spineIndex}`;
|
|
|
|
if (spineItemId) {
|
|
cfi += `[${spineItemId}]`;
|
|
}
|
|
|
|
for (const index of elementPath) {
|
|
cfi += `/${index}`;
|
|
}
|
|
|
|
if (textOffset > 0) {
|
|
cfi += `:${textOffset}`;
|
|
}
|
|
|
|
return cfi;
|
|
}
|
|
|
|
export function navigateToCFI(doc: Document, cfi: string): Element | Text | null {
|
|
const components = parseCFI(cfi);
|
|
|
|
if (components.length === 0) return null;
|
|
|
|
let current: Node | null = doc.body;
|
|
|
|
for (let i = 1; i < components.length; i++) {
|
|
const component = components[i];
|
|
|
|
if (component.type === 'index') {
|
|
if (current instanceof Element) {
|
|
const children = getElementChildren(current);
|
|
current = children[component.value] || null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return current as Element | Text;
|
|
}
|
|
|
|
export function getSelectionCFI(doc: Document): string | null {
|
|
const selection = window.getSelection();
|
|
if (!selection || selection.rangeCount === 0) return null;
|
|
|
|
const range = selection.getRangeAt(0);
|
|
const startContainer = range.startContainer;
|
|
|
|
// Build path to start container
|
|
const path: number[] = [];
|
|
let current: Node | null = startContainer;
|
|
|
|
while (current && current !== doc.body) {
|
|
const parent = current.parentElement;
|
|
if (parent) {
|
|
const siblings = getElementChildren(parent);
|
|
const index = siblings.indexOf(current as Element);
|
|
path.unshift(index);
|
|
}
|
|
current = parent;
|
|
}
|
|
|
|
const spineIndex = 0;
|
|
const textOffset = range.startOffset;
|
|
|
|
return generateCFI(spineIndex, path, textOffset);
|
|
}
|
|
|
|
export function getPercentageFromCFI(cfi: string): number {
|
|
const components = parseCFI(cfi);
|
|
const textLocation = components.find(c => c.type === 'text-location');
|
|
|
|
if (textLocation && textLocation.textOffset !== undefined) {
|
|
return Math.min((textLocation.textOffset / 10), 100);
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
export function compareCFIs(cfi1: string, cfi2: string): number {
|
|
const components1 = parseCFI(cfi1);
|
|
const components2 = parseCFI(cfi2);
|
|
|
|
const maxLen = Math.max(components1.length, components2.length);
|
|
|
|
for (let i = 0; i < maxLen; i++) {
|
|
const comp1 = components1[i];
|
|
const comp2 = components2[i];
|
|
|
|
if (!comp1) return -1;
|
|
if (!comp2) return 1;
|
|
|
|
if (comp1.value !== comp2.value) {
|
|
return comp1.value - comp2.value;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function getElementChildren(element: Element): Element[] {
|
|
return Array.from(element.children).filter(el =>
|
|
el.nodeType === Node.ELEMENT_NODE
|
|
) as Element[];
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 5.9 Ebook Search (Procedural)
|
|
|
|
**File:** `web/src/reader/ebook/search.ts`
|
|
|
|
```typescript
|
|
// Search within ebook content
|
|
// Procedural style: Functions, not classes
|
|
|
|
interface SearchResult {
|
|
cfi: string;
|
|
snippet: string;
|
|
chapterTitle: string;
|
|
}
|
|
|
|
interface EbookSearchConfig {
|
|
epubPackage: EPUBPackage;
|
|
}
|
|
|
|
// ============================================================
|
|
// Main Search Function
|
|
// ============================================================
|
|
|
|
export async function searchEbook(
|
|
epubPackage: EPUBPackage,
|
|
query: string
|
|
): Promise<SearchResult[]> {
|
|
const results: SearchResult[] = [];
|
|
const lowerQuery = query.toLowerCase();
|
|
|
|
// Search all spine items
|
|
for (const [index, spineItem] of epubPackage.spine.entries()) {
|
|
const doc = await getSpineItemDocument(epubPackage, spineItem);
|
|
|
|
if (!doc) continue;
|
|
|
|
const chapterTitle = getChapterTitle(spineItem);
|
|
|
|
// Search in text nodes
|
|
const textNodes = findTextNodes(doc.body);
|
|
|
|
for (const node of textNodes) {
|
|
const text = node.textContent || '';
|
|
const lowerText = text.toLowerCase();
|
|
|
|
let foundAt = 0;
|
|
while ((foundAt = lowerText.indexOf(lowerQuery, foundAt)) !== -1) {
|
|
const cfi = generateCFIForNode(node, foundAt);
|
|
const snippet = extractSnippet(text, foundAt, query.length);
|
|
|
|
results.push({
|
|
cfi,
|
|
snippet,
|
|
chapterTitle
|
|
});
|
|
|
|
foundAt += lowerQuery.length;
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
async function getSpineItemDocument(
|
|
epubPackage: EPUBPackage,
|
|
spineItem: EPUBSpineItem
|
|
): Promise<Document | null> {
|
|
try {
|
|
const content = await epubPackage.resources.get(spineItem.href)?.text();
|
|
if (!content) return null;
|
|
|
|
const parser = new DOMParser();
|
|
return parser.parseFromString(content, 'text/html');
|
|
} catch (error) {
|
|
console.error('Failed to load spine item:', spineItem.href, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getChapterTitle(spineItem: EPUBSpineItem): string {
|
|
// Extract title from spine item or use default
|
|
return spineItem.id || `Section ${spineItem.index}`;
|
|
}
|
|
|
|
function findTextNodes(root: Node): Text[] {
|
|
const textNodes: Text[] = [];
|
|
const walker = document.createTreeWalker(
|
|
root,
|
|
NodeFilter.SHOW_TEXT,
|
|
{
|
|
acceptNode: (node) => {
|
|
const parent = node.parentElement;
|
|
if (parent && ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(parent.tagName)) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
}
|
|
|
|
if (!node.textContent?.trim()) {
|
|
return NodeFilter.FILTER_REJECT;
|
|
}
|
|
|
|
return NodeFilter.FILTER_ACCEPT;
|
|
}
|
|
}
|
|
);
|
|
|
|
let node: Node | null;
|
|
while ((node = walker.nextNode())) {
|
|
textNodes.push(node as Text);
|
|
}
|
|
|
|
return textNodes;
|
|
}
|
|
|
|
function generateCFIForNode(node: Text, offset: number): string {
|
|
const path: number[] = [];
|
|
let current: Node | null = node;
|
|
|
|
while (current && current.parentNode) {
|
|
const parent = current.parentNode;
|
|
const siblings = Array.from(parent.childNodes)
|
|
.filter(n => n.nodeType === Node.ELEMENT_NODE);
|
|
const index = siblings.indexOf(current as Node);
|
|
|
|
path.unshift(index);
|
|
current = parent;
|
|
}
|
|
|
|
const spineIndex = 0; // Would come from parent context
|
|
|
|
return generateCFI(spineIndex, path, offset);
|
|
}
|
|
|
|
function extractSnippet(text: string, offset: number, length: number): string {
|
|
const contextBefore = 30;
|
|
const contextAfter = 50;
|
|
|
|
const start = Math.max(0, offset - contextBefore);
|
|
const end = Math.min(text.length, offset + length + contextAfter);
|
|
|
|
return text.slice(start, end);
|
|
}
|
|
```
|
|
|
|
### 5.10 Libre Reading Fonts (Bundled)
|
|
|
|
**8 Open Source Fonts Optimized for Extended Reading**
|
|
|
|
All fonts are bundled with Bookhoard using WOFF2 format (~1.2MB total). Standard weights only: Regular (400), Italic (400i), Bold (700), Bold Italic (700i).
|
|
|
|
**Font Directory:** `web/static/fonts/`
|
|
|
|
#### 5.10.1 Font Acquisition & Installation
|
|
|
|
**Automated Setup Script**
|
|
|
|
**File:** `scripts/fetch-reading-fonts.sh` (new file)
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
# Fetch and prepare libre reading fonts for Bookhoard
|
|
# Usage: ./scripts/fetch-reading-fonts.sh
|
|
|
|
set -e
|
|
|
|
FONTS_DIR="web/static/fonts"
|
|
mkdir -p "$FONTS_DIR"
|
|
|
|
echo "📦 Downloading libre reading fonts for Bookhoard..."
|
|
|
|
# 1. Literata (v2.001 - latest stable)
|
|
echo "Downloading Literata..."
|
|
wget -O /tmp/literata.zip "https://github.com/TypeNetwork/Literata/releases/download/v2.001/Literata-2.001.zip"
|
|
unzip -q /tmp/literata.zip -d /tmp/literata
|
|
mkdir -p "$FONTS_DIR/literata"
|
|
# Convert to WOFF2 using fonttools
|
|
for file in /tmp/literata/Static/*.otf; do
|
|
basename=$(basename "$file" .otf)
|
|
if [[ $basename == *"Regular"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-400.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF'
|
|
elif [[ $basename == *"Italic"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-400i.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF'
|
|
elif [[ $basename == *"Bold"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-700.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF'
|
|
elif [[ $basename == *"BoldItalic"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/literata/Literata-700i.woff2" --flavor=woff2 --layout-features='*' --unicodes='U+0000-10FFFF'
|
|
fi
|
|
done
|
|
rm -rf /tmp/literata /tmp/literata.zip
|
|
|
|
# 2. Crimson Text (v1.102)
|
|
echo "Downloading Crimson Text..."
|
|
wget -O /tmp/crimson.zip "https://github.com/SorkinType/Crimson-Pro/releases/download/v1.102/CrimsonPro-1.102.zip"
|
|
unzip -q /tmp/crimson.zip -d /tmp/crimson
|
|
mkdir -p "$FONTS_DIR/crimson"
|
|
for file in /tmp/crimson/OTF/CrimsonPro-*.otf; do
|
|
basename=$(basename "$file" .otf)
|
|
if [[ $basename == *"Roman"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-400.woff2" --flavor=woff2
|
|
elif [[ $basename == *"Roman-Italic"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-400i.woff2" --flavor=woff2
|
|
elif [[ $basename == *"Bold"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-700.woff2" --flavor=woff2
|
|
elif [[ $basename == *"BoldItalic"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/crimson/CrimsonText-700i.woff2" --flavor=woff2
|
|
fi
|
|
done
|
|
rm -rf /tmp/crimson /tmp/crimson.zip
|
|
|
|
# 3. Source Serif 4 (v4.004)
|
|
echo "Downloading Source Serif 4..."
|
|
wget -O /tmp/source-serif.zip "https://github.com/adobe-fonts/source-serif/releases/download/V4.004R/04_SourceSerif4-ItOtF.zip"
|
|
unzip -q /tmp/source-serif.zip -d /tmp/source-serif
|
|
mkdir -p "$FONTS_DIR/source-serif"
|
|
for file in /tmp/source-serif/OTF/SourceSerif4-*.otf; do
|
|
basename=$(basename "$file" .otf)
|
|
if [[ $basename == *"Regular"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-400.woff2" --flavor=woff2
|
|
elif [[ $basename == *"It"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-400i.woff2" --flavor=woff2
|
|
elif [[ $basename == *"Bold"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-700.woff2" --flavor=woff2
|
|
elif [[ $basename == *"BoldIt"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/source-serif/SourceSerif4-700i.woff2" --flavor=woff2
|
|
fi
|
|
done
|
|
rm -rf /tmp/source-serif /tmp/source-serif.zip
|
|
|
|
# 4. EB Garamond (v0.016)
|
|
echo "Downloading EB Garamond..."
|
|
wget -O /tmp/ebgaramond.zip "https://github.com/ebgaramond/EB-Garamond/releases/download/0.016/EBGaramond-0.016.zip"
|
|
unzip -q /tmp/ebgaramond.zip -d /tmp/ebgaramond
|
|
mkdir -p "$FONTS_DIR/eb-garamond"
|
|
for file in /tmp/ebgaramond/otf/*.otf; do
|
|
basename=$(basename "$file" .otf)
|
|
if [[ $basename == *"Regular"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-400.woff2" --flavor=woff2
|
|
elif [[ $basename == *"Italic"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-400i.woff2" --flavor=woff2
|
|
elif [[ $basename == *"Bold"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-700.woff2" --flavor=woff2
|
|
elif [[ $basename == *"BoldItalic"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/eb-garamond/EBGaramond-700i.woff2" --flavor=woff2
|
|
fi
|
|
done
|
|
rm -rf /tmp/ebgaramond /tmp/ebgaramond.zip
|
|
|
|
# 5. Libertinus Serif (v7.050)
|
|
echo "Downloading Libertinus Serif..."
|
|
wget -O /tmp/libertinus.zip "https://github.com/libertinus/libertinus/releases/download/v7.050/Libertinus-7.050.zip"
|
|
unzip -q /tmp/libertinus.zip -d /tmp/libertinus
|
|
mkdir -p "$FONTS_DIR/libertinus"
|
|
for file in /tmp/libertinus/LibertinusSerif-*.otf; do
|
|
basename=$(basename "$file" .otf)
|
|
if [[ $basename == *"Regular"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-400.woff2" --flavor=woff2
|
|
elif [[ $basename == *"Italic"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-400i.woff2" --flavor=woff2
|
|
elif [[ $basename == *"Bold"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-700.woff2" --flavor=woff2
|
|
elif [[ $basename == *"BoldItalic"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/libertinus/LibertinusSerif-700i.woff2" --flavor=woff2
|
|
fi
|
|
done
|
|
rm -rf /tmp/libertinus /tmp/libertinus.zip
|
|
|
|
# 6. Noto Serif (v2.013 - subset to common languages only to reduce size)
|
|
echo "Downloading Noto Serif..."
|
|
wget -O /tmp/noto-serif.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Regular.ttf"
|
|
pyftsubset /tmp/noto-serif.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-400.woff2" --flavor=woff2 --unicodes='U+0000-007F' --text-file="common-latin.txt"
|
|
wget -O /tmp/noto-serif-i.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Italic.ttf"
|
|
pyftsubset /tmp/noto-serif-i.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-400i.woff2" --flavor=woff2 --unicodes='U+0000-007F'
|
|
wget -O /tmp/noto-serif-b.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Bold.ttf"
|
|
pyftsubset /tmp/noto-serif-b.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-700.woff2" --flavor=woff2 --unicodes='U+0000-007F'
|
|
wget -O /tmp/noto-serif-bi.zip "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-BoldItalic.ttf"
|
|
pyftsubset /tmp/noto-serif-bi.zip --output-file="$FONTS_DIR/noto-serif/NotoSerif-700i.woff2" --flavor=woff2 --unicodes='U+0000-007F'
|
|
rm -f /tmp/noto-serif*.zip
|
|
|
|
# 7. Charis SIL (v6.200)
|
|
echo "Downloading Charis SIL..."
|
|
wget -O /tmp/charis.zip "https://github.com/silnrsi/font-charis/releases/download/v6.200/CharisSIL-6.200.zip"
|
|
unzip -q /tmp/charis.zip -d /tmp/charis
|
|
mkdir -p "$FONTS_DIR/charis-sil"
|
|
for file in /tmp/charis/CharisSIL-6.200/*.ttf; do
|
|
basename=$(basename "$file" .ttf)
|
|
if [[ $basename == *"Regular"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-400.woff2" --flavor=woff2
|
|
elif [[ $basename == *"Italic"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-400i.woff2" --flavor=woff2
|
|
elif [[ $basename == *"Bold"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-700.woff2" --flavor=woff2
|
|
elif [[ $basename == *"BoldItalic"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/charis-sil/CharisSIL-700i.woff2" --flavor=woff2
|
|
fi
|
|
done
|
|
rm -rf /tmp/charis /tmp/charis.zip
|
|
|
|
# 8. IBM Plex Serif (v1.1.0)
|
|
echo "Downloading IBM Plex Serif..."
|
|
wget -O /tmp/ibm-plex.zip "https://github.com/IBM/plex/releases/download/v1.1.0/OpenTypePackage.zip"
|
|
unzip -q /tmp/ibm-plex.zip -d /tmp/ibm-plex
|
|
mkdir -p "$FONTS_DIR/ibm-plex"
|
|
for file in /tmp/ibm-plex/OpenType/IBM-Plex-Serif/*.otf; do
|
|
basename=$(basename "$file" .otf)
|
|
if [[ $basename == *"Regular"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-400.woff2" --flavor=woff2
|
|
elif [[ $basename == *"TextItalic"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-400i.woff2" --flavor=woff2
|
|
elif [[ $basename == *"SemiBold"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-700.woff2" --flavor=woff2
|
|
elif [[ $basename == *"SemiBoldItalic"* ]]; then
|
|
pyftsubset "$file" --output-file="$FONTS_DIR/ibm-plex/IBMPlexSerif-700i.woff2" --flavor=woff2
|
|
fi
|
|
done
|
|
rm -rf /tmp/ibm-plex /tmp/ibm-plex.zip
|
|
|
|
echo "✅ All fonts downloaded and converted to WOFF2"
|
|
echo "📊 Total size:"
|
|
du -sh "$FONTS_DIR"
|
|
|
|
echo "🔍 Verifying fonts..."
|
|
ls -lh "$FONTS_DIR"/*/
|
|
|
|
echo "✨ Font setup complete!"
|
|
```
|
|
|
|
**Manual Setup (Alternative)**
|
|
|
|
If you prefer manual setup or the script fails:
|
|
|
|
| Font | Version | Download URL | License |
|
|
|------|---------|-------------|---------|
|
|
| **Literata** | v2.001 | https://github.com/TypeNetwork/Literata/releases/download/v2.001/Literata-2.001.zip | SIL OFL 1.1 |
|
|
| **Crimson Text** | v1.102 | https://github.com/SorkinType/Crimson-Pro/releases/download/v1.102/CrimsonPro-1.102.zip | SIL OFL 1.1 |
|
|
| **Source Serif 4** | v4.004 | https://github.com/adobe-fonts/source-serif/releases/download/V4.004R/04_SourceSerif4-ItOtF.zip | SIL OFL 1.1 |
|
|
| **EB Garamond** | v0.016 | https://github.com/ebgaramond/EB-Garamond/releases/download/0.016/EBGaramond-0.016.zip | SIL OFL 1.1 |
|
|
| **Libertinus Serif** | v7.050 | https://github.com/libertinus/libertinus/releases/download/v7.050/Libertinus-7.050.zip | SIL OFL 1.1 |
|
|
| **Noto Serif** | v2.013 | https://github.com/googlefonts/noto-fonts (subset to Latin-1) | SIL OFL 1.1 |
|
|
| **Charis SIL** | v6.200 | https://github.com/silnrsi/font-charis/releases/download/v6.200/CharisSIL-6.200.zip | SIL OFL 1.1 |
|
|
| **IBM Plex Serif** | v1.1.0 | https://github.com/IBM/plex/releases/download/v1.1.0/OpenTypePackage.zip | SIL OFL 1.1 |
|
|
|
|
#### 5.10.2 Font Conversion Requirements
|
|
|
|
**Required Tools:**
|
|
|
|
```bash
|
|
# Python fonttools for WOFF2 conversion
|
|
pip install fonttools brotli
|
|
|
|
# Alternative: Google Fonts woff2 tool
|
|
git clone --recursive https://github.com/google/woff2.git
|
|
cd woff2
|
|
make
|
|
sudo cp woff2_compress /usr/local/bin/
|
|
sudo cp woff2_decompress /usr/local/bin/
|
|
```
|
|
|
|
**Conversion Commands:**
|
|
|
|
```bash
|
|
# Using fonttools (recommended)
|
|
pyftsubset input.otf --output-file=output.woff2 \
|
|
--flavor=woff2 \
|
|
--layout-features='*' \
|
|
--unicodes='U+0000-10FFFF'
|
|
|
|
# Using woff2_compress tool
|
|
woff2_compress input.otf output.woff2
|
|
```
|
|
|
|
#### 5.10.3 Font Verification
|
|
|
|
**Verify fonts are working:**
|
|
|
|
```bash
|
|
# List all fonts
|
|
ls -lh web/static/fonts/*/
|
|
|
|
# Check file sizes (should be ~100-200KB each)
|
|
du -h web/static/fonts/*/*.*
|
|
|
|
# Verify WOFF2 format
|
|
file web/static/fonts/*/*.woff2
|
|
|
|
# Should output: "WOFF2 font data"
|
|
```
|
|
|
|
**Add to git:**
|
|
|
|
```bash
|
|
# Add fonts to repository
|
|
git add web/static/fonts/
|
|
|
|
# Commit
|
|
git commit -m "feat: add 8 bundled libre reading fonts
|
|
|
|
- Literata (default)
|
|
- Crimson Text
|
|
- Source Serif 4
|
|
- EB Garamond
|
|
- Libertinus Serif
|
|
- Noto Serif
|
|
- Charis SIL
|
|
- IBM Plex Serif
|
|
|
|
All fonts use SIL Open Font License 1.1
|
|
WOFF2 format, ~1.2MB total"
|
|
```
|
|
|
|
#### 5.10.4 Alternative: Use Google Fonts CDN (Not Recommended)
|
|
|
|
If you don't want to bundle fonts (slower initial load, privacy concerns):
|
|
|
|
```html
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400&family=EB+Garamond:ital,wght@0,400;0,700;1,400&family=Literata:wght@0,400;0,700;1,400&family=Libertinus+Serif:wght@0,400;0,700;1,400&family=Noto+Serif:wght@0,400;0,700;1,400&family=Source+Serif+4:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
|
|
```
|
|
|
|
**Why bundling is better:**
|
|
- ✅ Offline-ready (no network requests)
|
|
- ✅ Privacy (Google doesn't track usage)
|
|
- ✅ Faster (no DNS lookup, no TLS handshake)
|
|
- ✅ Control (exact versions, no breaking changes)
|
|
|
|
#### 5.10.6 Font Subsetting for Language Support
|
|
|
|
**Full Unicode vs. Latin-1 Subset:**
|
|
|
|
- **Full Unicode**: ~200KB per style (supports all languages)
|
|
- **Latin-1 Subset**: ~50KB per style (supports Western European languages)
|
|
|
|
**Recommendation:** Bundle full Unicode for most fonts, but subset Noto Serif to Latin-1 unless you need extensive language support.
|
|
|
|
**Subset Noto Serif (Latin-1 only):**
|
|
|
|
```bash
|
|
pyftsubset NotoSerif-Regular.ttf \
|
|
--output-file=NotoSerif-400.woff2 \
|
|
--flavor=woff2 \
|
|
--unicodes='U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215'
|
|
```
|
|
|
|
This reduces Noto Serif from ~180KB to ~50KB per style.
|
|
|
|
#### 5.10.7 Font Loading Performance
|
|
|
|
**Critical Rendering Path Optimization:**
|
|
|
|
```typescript
|
|
// Preload default font (Literata) in HTML head
|
|
<link rel="preload" href="/static/fonts/literata/Literata-400.woff2" as="font" type="font/woff2" crossorigin>
|
|
|
|
// Preload user's preferred font (from settings)
|
|
<link rel="preload" href="/static/fonts/crimson/CrimsonText-400.woff2" as="font" type="font/woff2" crossorigin>
|
|
```
|
|
|
|
**Lazy-load other fonts:**
|
|
|
|
```typescript
|
|
// Load other fonts on demand
|
|
async function loadFont(fontId: string): Promise<void> {
|
|
const font = READING_FONTS.find(f => f.id === fontId);
|
|
if (!font) return;
|
|
|
|
document.fonts.load(`16px "${font.stack}"`);
|
|
}
|
|
```
|
|
|
|
```
|
|
web/static/fonts/
|
|
├── literata/
|
|
│ ├── Literata-400.woff2 (200KB)
|
|
│ ├── Literata-400i.woff2 (200KB)
|
|
│ ├── Literata-700.woff2 (180KB)
|
|
│ └── Literata-700i.woff2 (180KB)
|
|
├── crimson/
|
|
│ ├── CrimsonText-400.woff2 (100KB)
|
|
│ ├── CrimsonText-400i.woff2 (100KB)
|
|
│ ├── CrimsonText-700.woff2 (95KB)
|
|
│ └── CrimsonText-700i.woff2 (95KB)
|
|
├── source-serif/
|
|
│ ├── SourceSerif4-400.woff2 (150KB)
|
|
│ ├── SourceSerif4-400i.woff2 (150KB)
|
|
│ ├── SourceSerif4-700.woff2 (145KB)
|
|
│ └── SourceSerif4-700i.woff2 (145KB)
|
|
├── eb-garamond/
|
|
│ ├── EBGaramond-400.woff2 (120KB)
|
|
│ ├── EBGaramond-400i.woff2 (120KB)
|
|
│ ├── EBGaramond-700.woff2 (115KB)
|
|
│ └── EBGaramond-700i.woff2 (115KB)
|
|
├── libertinus/
|
|
│ ├── LibertinusSerif-400.woff2 (150KB)
|
|
│ ├── LibertinusSerif-400i.woff2 (150KB)
|
|
│ ├── LibertinusSerif-700.woff2 (145KB)
|
|
│ └── LibertinusSerif-700i.woff2 (145KB)
|
|
├── noto-serif/
|
|
│ ├── NotoSerif-400.woff2 (180KB)
|
|
│ ├── NotoSerif-400i.woff2 (180KB)
|
|
│ ├── NotoSerif-700.woff2 (175KB)
|
|
│ └── NotoSerif-700i.woff2 (175KB)
|
|
├── charis-sil/
|
|
│ ├── CharisSIL-400.woff2 (130KB)
|
|
│ ├── CharisSIL-400i.woff2 (130KB)
|
|
│ ├── CharisSIL-700.woff2 (125KB)
|
|
│ └── CharisSIL-700i.woff2 (125KB)
|
|
└── ibm-plex/
|
|
├── IBMPlexSerif-400.woff2 (140KB)
|
|
├── IBMPlexSerif-400i.woff2 (140KB)
|
|
├── IBMPlexSerif-700.woff2 (135KB)
|
|
└── IBMPlexSerif-700i.woff2 (135KB)
|
|
```
|
|
|
|
**File:** `web/static/reader-fonts.css` (new file)
|
|
|
|
```css
|
|
/* Libre reading fonts for Bookhoard ebook reader */
|
|
|
|
/* Literata - Designed for Google Play Books */
|
|
@font-face {
|
|
font-family: 'Literata';
|
|
src: url('/static/fonts/literata/Literata-400.woff2') format('woff2');
|
|
font-weight: 400;
|
|
font-style: normal;
|
|
}
|
|
@font-face {
|
|
font-family: 'Literata';
|
|
src: url('/static/fonts/literata/Literata-400i.woff2') format('woff2');
|
|
font-weight: 400;
|
|
font-style: italic;
|
|
}
|
|
@font-face {
|
|
font-family: 'Literata';
|
|
src: url('/static/fonts/literata/Literata-700.woff2') format('woff2');
|
|
font-weight: 700;
|
|
font-style: normal;
|
|
}
|
|
@font-face {
|
|
font-family: 'Literata';
|
|
src: url('/static/fonts/literata/Literata-700i.woff2') format('woff2');
|
|
font-weight: 700;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* Crimson Text - Optimized for screen reading */
|
|
@font-face {
|
|
font-family: 'Crimson Text';
|
|
src: url('/static/fonts/crimson/CrimsonText-400.woff2') format('woff2');
|
|
font-weight: 400;
|
|
font-style: normal;
|
|
}
|
|
@font-face {
|
|
font-family: 'Crimson Text';
|
|
src: url('/static/fonts/crimson/CrimsonText-400i.woff2') format('woff2');
|
|
font-weight: 400;
|
|
font-style: italic;
|
|
}
|
|
@font-face {
|
|
font-family: 'Crimson Text';
|
|
src: url('/static/fonts/crimson/CrimsonText-700.woff2') format('woff2');
|
|
font-weight: 700;
|
|
font-style: normal;
|
|
}
|
|
@font-face {
|
|
font-family: 'Crimson Text';
|
|
src: url('/static/fonts/crimson/CrimsonText-700i.woff2') format('woff2');
|
|
font-weight: 700;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* Source Serif 4 - Adobe professional quality */
|
|
@font-face {
|
|
font-family: 'Source Serif 4';
|
|
src: url('/static/fonts/source-serif/SourceSerif4-400.woff2') format('woff2');
|
|
font-weight: 400;
|
|
font-style: normal;
|
|
}
|
|
@font-face {
|
|
font-family: 'Source Serif 4';
|
|
src: url('/static/fonts/source-serif/SourceSerif4-400i.woff2') format('woff2');
|
|
font-weight: 400;
|
|
font-style: italic;
|
|
}
|
|
@font-face {
|
|
font-family: 'Source Serif 4';
|
|
src: url('/static/fonts/source-serif/SourceSerif4-700.woff2') format('woff2');
|
|
font-weight: 700;
|
|
font-style: normal;
|
|
}
|
|
@font-face {
|
|
font-family: 'Source Serif 4';
|
|
src: url('/static/fonts/source-serif/SourceSerif4-700i.woff2') format('woff2');
|
|
font-weight: 700;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* EB Garamond - Classic elegance */
|
|
@font-face {
|
|
font-family: 'EB Garamond';
|
|
src: url('/static/fonts/eb-garamond/EBGaramond-400.woff2') format('woff2');
|
|
font-weight: 400;
|
|
font-style: normal;
|
|
}
|
|
@font-face {
|
|
font-family: 'EB Garamond';
|
|
src: url('/static/fonts/eb-garamond/EBGaramond-400i.woff2') format('woff2');
|
|
font-weight: 400;
|
|
font-style: italic;
|
|
}
|
|
@font-face {
|
|
font-family: 'EB Garamond';
|
|
src: url('/static/fonts/eb-garamond/EBGaramond-700.woff2') format('woff2');
|
|
font-weight: 700;
|
|
font-style: normal;
|
|
}
|
|
@font-face {
|
|
font-family: 'EB Garamond';
|
|
src: url('/static/fonts/eb-garamond/EBGaramond-700i.woff2') format('woff2');
|
|
font-weight: 700;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* Libertinus Serif - Academic/technical */
|
|
@font-face {
|
|
font-family: 'Libertinus Serif';
|
|
src: url('/static/fonts/libertinus/LibertinusSerif-400.woff2') format('woff2');
|
|
font-weight: 400;
|
|
font-style: normal;
|
|
}
|
|
@font-face {
|
|
font-family: 'Libertinus Serif';
|
|
src: url('/static/fonts/libertinus/LibertinusSerif-400i.woff2') format('woff2');
|
|
font-weight: 400;
|
|
font-style: italic;
|
|
}
|
|
@font-face {
|
|
font-family: 'Libertinus Serif';
|
|
src: url('/static/fonts/libertinus/LibertinusSerif-700.woff2') format('woff2');
|
|
font-weight: 700;
|
|
font-style: normal;
|
|
}
|
|
@font-face {
|
|
font-family: 'Libertinus Serif';
|
|
src: url('/static/fonts/libertinus/LibertinusSerif-700i.woff2') format('woff2');
|
|
font-weight: 700;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* Noto Serif - Maximum language support */
|
|
@font-face {
|
|
font-family: 'Noto Serif';
|
|
src: url('/static/fonts/noto-serif/NotoSerif-400.woff2') format('woff2');
|
|
font-weight: 400;
|
|
font-style: normal;
|
|
}
|
|
@font-face {
|
|
font-family: 'Noto Serif';
|
|
src: url('/static/fonts/noto-serif/NotoSerif-400i.woff2') format('woff2');
|
|
font-weight: 400;
|
|
font-style: italic;
|
|
}
|
|
@font-face {
|
|
font-family: 'Noto Serif';
|
|
src: url('/static/fonts/noto-serif/NotoSerif-700.woff2') format('woff2');
|
|
font-weight: 700;
|
|
font-style: normal;
|
|
}
|
|
@font-face {
|
|
font-family: 'Noto Serif';
|
|
src: url('/static/fonts/noto-serif/NotoSerif-700i.woff2') format('woff2');
|
|
font-weight: 700;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* Charis SIL - Multilingual specialist */
|
|
@font-face {
|
|
font-family: 'Charis SIL';
|
|
src: url('/static/fonts/charis-sil/CharisSIL-400.woff2') format('woff2');
|
|
font-weight: 400;
|
|
font-style: normal;
|
|
}
|
|
@font-face {
|
|
font-family: 'Charis SIL';
|
|
src: url('/static/fonts/charis-sil/CharisSIL-400i.woff2') format('woff2');
|
|
font-weight: 400;
|
|
font-style: italic;
|
|
}
|
|
@font-face {
|
|
font-family: 'Charis SIL';
|
|
src: url('/static/fonts/charis-sil/CharisSIL-700.woff2') format('woff2');
|
|
font-weight: 700;
|
|
font-style: normal;
|
|
}
|
|
@font-face {
|
|
font-family: 'Charis SIL';
|
|
src: url('/static/fonts/charis-sil/CharisSIL-700i.woff2') format('woff2');
|
|
font-weight: 700;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* IBM Plex Serif - Modern & versatile */
|
|
@font-face {
|
|
font-family: 'IBM Plex Serif';
|
|
src: url('/static/fonts/ibm-plex/IBMPlexSerif-400.woff2') format('woff2');
|
|
font-weight: 400;
|
|
font-style: normal;
|
|
}
|
|
@font-face {
|
|
font-family: 'IBM Plex Serif';
|
|
src: url('/static/fonts/ibm-plex/IBMPlexSerif-400i.woff2') format('woff2');
|
|
font-weight: 400;
|
|
font-style: italic;
|
|
}
|
|
@font-face {
|
|
font-family: 'IBM Plex Serif';
|
|
src: url('/static/fonts/ibm-plex/IBMPlexSerif-700.woff2') format('woff2');
|
|
font-weight: 700;
|
|
font-style: normal;
|
|
}
|
|
@font-face {
|
|
font-family: 'IBM Plex Serif';
|
|
src: url('/static/fonts/ibm-plex/IBMPlexSerif-700i.woff2') format('woff2');
|
|
font-weight: 700;
|
|
font-style: italic;
|
|
}
|
|
```
|
|
|
|
**Font Loading Strategy:**
|
|
|
|
**File:** `web/src/reader/ebook/font-loader.ts` (new file)
|
|
|
|
```typescript
|
|
// Font loading with performance optimization
|
|
|
|
const READING_FONTS = [
|
|
{
|
|
id: 'literata',
|
|
name: 'Literata',
|
|
stack: 'Literata, serif',
|
|
description: 'Designed for Google Play Books'
|
|
},
|
|
{
|
|
id: 'crimson',
|
|
name: 'Crimson Text',
|
|
stack: 'Crimson Text, serif',
|
|
description: 'Optimized for screen reading'
|
|
},
|
|
{
|
|
id: 'source-serif',
|
|
name: 'Source Serif 4',
|
|
stack: 'Source Serif 4, serif',
|
|
description: 'Professional Adobe quality'
|
|
},
|
|
{
|
|
id: 'eb-garamond',
|
|
name: 'EB Garamond',
|
|
stack: 'EB Garamond, serif',
|
|
description: 'Classic elegance'
|
|
},
|
|
{
|
|
id: 'libertinus',
|
|
name: 'Libertinus Serif',
|
|
stack: 'Libertinus Serif, serif',
|
|
description: 'Excellent for technical content'
|
|
},
|
|
{
|
|
id: 'noto-serif',
|
|
name: 'Noto Serif',
|
|
stack: 'Noto Serif, serif',
|
|
description: 'Maximum language support'
|
|
},
|
|
{
|
|
id: 'charis-sil',
|
|
name: 'Charis SIL',
|
|
stack: 'Charis SIL, serif',
|
|
description: 'Multilingual specialist'
|
|
},
|
|
{
|
|
id: 'ibm-plex',
|
|
name: 'IBM Plex Serif',
|
|
stack: 'IBM Plex Serif, serif',
|
|
description: 'Modern & versatile'
|
|
}
|
|
];
|
|
|
|
// Preload critical fonts (default font + user's last choice)
|
|
async function preloadFonts(userPreferredFont: string): Promise<void> {
|
|
const fontsToPreload = new Set(['literata', userPreferredFont]);
|
|
|
|
for (const fontId of fontsToPreload) {
|
|
const font = READING_FONTS.find(f => f.id === fontId);
|
|
if (font) {
|
|
document.fonts.load(`16px "${font.stack}"`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get font stack for CSS
|
|
function getFontStack(fontId: string): string {
|
|
const font = READING_FONTS.find(f => f.id === fontId);
|
|
return font?.stack || 'Literata, serif';
|
|
}
|
|
|
|
// All fonts bundled - no network requests needed
|
|
export { READING_FONTS, preloadFonts, getFontStack };
|
|
```
|
|
|
|
**Important Notes:**
|
|
|
|
- **UI Elements**: Use Bookhoard's existing font stack (not these reading fonts)
|
|
- **Ebook Content Only**: These fonts apply only to `.ebook-content` elements
|
|
- **Bundled**: All fonts ship with the app (~1.2MB total, WOFF2 compressed)
|
|
- **Offline Ready**: No network requests needed for font loading
|
|
- **Performance**: Preload default font (Literata) + user's preference
|
|
- **License**: All fonts use SIL Open Font License 1.1 (libre, commercial use OK)
|
|
|
|
### 5.11 Typography Engine
|
|
|
|
**File:** `web/src/reader/ebook/typography-engine.ts`
|
|
|
|
```typescript
|
|
// Typography engine with font smoothing, hyphenation, and justification
|
|
|
|
interface TypographyConfig {
|
|
readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex'; // Bundled libre fonts
|
|
fontSize: number;
|
|
lineHeight: number;
|
|
marginTop: number;
|
|
marginBottom: number;
|
|
marginLeft: number;
|
|
marginRight: number;
|
|
textAlign: 'left' | 'right' | 'center' | 'justify';
|
|
textIndent: number;
|
|
hyphenate: boolean;
|
|
ligatures: boolean;
|
|
fontSmoothing: 'auto' | 'antialiased' | 'subpixel-antialiased';
|
|
}
|
|
|
|
// Typography engine for ebook text rendering
|
|
// Procedural implementation (no OOP)
|
|
|
|
interface TypographyConfig {
|
|
readingFont: 'literata' | 'crimson' | 'source-serif' | 'eb-garamond' | 'libertinus' | 'noto-serif' | 'charis-sil' | 'ibm-plex';
|
|
fontSize: number;
|
|
lineHeight: number;
|
|
textAlign: 'left' | 'justify';
|
|
marginTop: number;
|
|
marginBottom: number;
|
|
marginLeft: number;
|
|
marginRight: number;
|
|
textIndent: number;
|
|
fontSmoothing: 'antialiased' | 'auto' | 'grayscale';
|
|
hyphenate: boolean;
|
|
ligatures: boolean;
|
|
}
|
|
|
|
function applyTypography(container: HTMLElement, config: TypographyConfig): void {
|
|
const content = container.querySelector('.ebook-content');
|
|
if (!content) return;
|
|
|
|
const fontStack = getFontStack(config.readingFont);
|
|
|
|
content.setAttribute('style', `
|
|
font-family: ${fontStack};
|
|
font-size: ${config.fontSize}px;
|
|
line-height: ${config.lineHeight};
|
|
text-align: ${config.textAlign};
|
|
margin-top: ${config.marginTop}px;
|
|
margin-bottom: ${config.marginBottom}px;
|
|
margin-left: ${config.marginLeft}px;
|
|
margin-right: ${config.marginRight}px;
|
|
text-indent: ${config.textIndent}px;
|
|
-webkit-font-smoothing: ${config.fontSmoothing};
|
|
-moz-osx-font-smoothing: ${config.fontSmoothing === 'grayscale' ? 'grayscale' : 'auto'};
|
|
`);
|
|
|
|
if (config.hyphenate) {
|
|
enableHyphenation(container, content as HTMLElement);
|
|
}
|
|
|
|
setLigatures(content as HTMLElement, config.ligatures);
|
|
|
|
if (config.textAlign === 'justify') {
|
|
enableJustification(content as HTMLElement);
|
|
}
|
|
}
|
|
|
|
function enableHyphenation(container: HTMLElement, element: HTMLElement): void {
|
|
element.style.hyphens = 'auto';
|
|
element.style.hyphenateLimitChars = '6 3 3';
|
|
|
|
const lang = container.closest('[data-language]')?.getAttribute('data-language') || 'en';
|
|
element.setAttribute('lang', lang);
|
|
}
|
|
|
|
function setLigatures(element: HTMLElement, enabled: boolean): void {
|
|
if (enabled) {
|
|
element.style.fontVariantLigatures = 'common-ligatures';
|
|
element.style.fontFeatureSettings = '"liga", "dlig"';
|
|
} else {
|
|
element.style.fontVariantLigatures = 'no-common-ligatures';
|
|
element.style.fontFeatureSettings = 'normal';
|
|
}
|
|
}
|
|
|
|
function enableJustification(element: HTMLElement): void {
|
|
element.style.wordBreak = 'normal';
|
|
element.style.overflowWrap = 'break-word';
|
|
element.style.wordWrap = 'break-word';
|
|
element.style.letterSpacing = '0.01em';
|
|
}
|
|
|
|
function updateTypographyConfig(
|
|
currentConfig: TypographyConfig,
|
|
newConfig: Partial<TypographyConfig>
|
|
): TypographyConfig {
|
|
return { ...currentConfig, ...newConfig };
|
|
}
|
|
|
|
function measureReadingTime(container: HTMLElement, wordsPerMinute: number = 250): number {
|
|
const content = container.querySelector('.ebook-content');
|
|
if (!content) return 0;
|
|
|
|
const text = content.textContent || '';
|
|
const words = text.split(/\s+/).length;
|
|
const minutes = words / wordsPerMinute;
|
|
|
|
return Math.ceil(minutes);
|
|
}
|
|
|
|
function getWordCount(container: HTMLElement): number {
|
|
const content = container.querySelector('.ebook-content');
|
|
if (!content) return 0;
|
|
|
|
const text = content.textContent || '';
|
|
return text.split(/\s+/).length;
|
|
}
|
|
```
|
|
|
|
### 5.13 Copy Text Handler
|
|
|
|
**File:** `web/src/reader/ebook/copy-handler.ts`
|
|
|
|
```typescript
|
|
// Handle text copying with citation
|
|
|
|
// Handle text copying with citation
|
|
// Procedural implementation (no OOP)
|
|
|
|
async function copySelection(mediaItem: MediaItemSummary): Promise<boolean> {
|
|
const selection = window.getSelection();
|
|
if (!selection || selection.rangeCount === 0) return false;
|
|
|
|
const selectedText = selection.toString();
|
|
if (!selectedText.trim()) return false;
|
|
|
|
const citation = createCitation(selectedText, mediaItem);
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(citation);
|
|
showToast('Copied to clipboard', 'success');
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Failed to copy:', error);
|
|
showToast('Failed to copy to clipboard', 'error');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function createCitation(text: string, mediaItem: MediaItemSummary): string {
|
|
let citation = `"${text}"\n`;
|
|
citation += `— ${mediaItem.title}`;
|
|
if (mediaItem.author) {
|
|
citation += ` by ${mediaItem.author}`;
|
|
}
|
|
citation += `\n(Source: Bookhoard)`;
|
|
|
|
return citation;
|
|
}
|
|
|
|
function enableContextMenuCopy(mediaItem: MediaItemSummary): void {
|
|
document.addEventListener('contextmenu', async (e) => {
|
|
const selection = window.getSelection();
|
|
const selectedText = selection?.toString().trim();
|
|
|
|
if (selectedText) {
|
|
e.preventDefault();
|
|
await copySelection(mediaItem);
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
### 5.14 View Modes
|
|
|
|
**File:** `web/src/reader/ebook/view-modes.ts`
|
|
|
|
```typescript
|
|
// Different viewing modes for ebooks
|
|
|
|
type ViewMode = 'paginated' | 'scrolled' | 'single-column' | 'double-column';
|
|
|
|
// Different viewing modes for ebooks
|
|
// Procedural implementation (no OOP)
|
|
|
|
type ViewMode = 'paginated' | 'scrolled' | 'single-column' | 'double-column';
|
|
|
|
interface ViewModeState {
|
|
currentMode: ViewMode;
|
|
currentPage: number;
|
|
}
|
|
|
|
function setViewMode(container: HTMLElement, mode: ViewMode): void {
|
|
const content = container.querySelector('.ebook-content');
|
|
if (!content) return;
|
|
|
|
content.classList.remove(
|
|
'paginated',
|
|
'scrolled',
|
|
'single-column',
|
|
'double-column'
|
|
);
|
|
|
|
switch (mode) {
|
|
case 'paginated':
|
|
applyPaginatedMode(container, content as HTMLElement);
|
|
break;
|
|
case 'scrolled':
|
|
applyScrolledMode(container, content as HTMLElement);
|
|
break;
|
|
case 'single-column':
|
|
applySingleColumn(content as HTMLElement);
|
|
break;
|
|
case 'double-column':
|
|
applyDoubleColumn(content as HTMLElement);
|
|
break;
|
|
}
|
|
}
|
|
|
|
function applyPaginatedMode(container: HTMLElement, element: HTMLElement): void {
|
|
element.classList.add('paginated');
|
|
|
|
element.style.height = '100vh';
|
|
element.style.overflow = 'hidden';
|
|
element.style.columnCount = '1';
|
|
element.style.columnGap = '0';
|
|
|
|
enablePagination(container, element);
|
|
}
|
|
|
|
function applyScrolledMode(container: HTMLElement, element: HTMLElement): void {
|
|
element.classList.add('scrolled');
|
|
|
|
element.style.height = 'auto';
|
|
element.style.overflowY = 'auto';
|
|
element.style.columnCount = '1';
|
|
|
|
disablePagination(container);
|
|
}
|
|
|
|
function applySingleColumn(element: HTMLElement): void {
|
|
element.classList.add('single-column');
|
|
|
|
element.style.columnCount = '1';
|
|
element.style.columnGap = '0';
|
|
element.style.maxWidth = '800px';
|
|
element.style.margin = '0 auto';
|
|
}
|
|
|
|
function applyDoubleColumn(element: HTMLElement): void {
|
|
element.classList.add('double-column');
|
|
|
|
element.style.columnCount = '2';
|
|
element.style.columnGap = '60px';
|
|
element.style.columnRule = '1px solid var(--text-secondary)';
|
|
element.style.maxWidth = '1400px';
|
|
element.style.margin = '0 auto';
|
|
}
|
|
|
|
function enablePagination(container: HTMLElement, element: HTMLElement): void {
|
|
const totalHeight = element.scrollHeight;
|
|
const pageHeight = element.clientHeight;
|
|
const pageCount = Math.ceil(totalHeight / pageHeight);
|
|
|
|
addPaginationControls(container, pageCount);
|
|
}
|
|
|
|
function disablePagination(container: HTMLElement): void {
|
|
const controls = container.querySelector('.pagination-controls');
|
|
controls?.remove();
|
|
}
|
|
|
|
function addPaginationControls(container: HTMLElement, pageCount: number): ViewModeState {
|
|
let currentPage = 1;
|
|
|
|
const controls = document.createElement('div');
|
|
controls.className = 'pagination-controls fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t';
|
|
controls.innerHTML = `
|
|
<button class="prev-page" ${currentPage === 1 ? 'disabled' : ''}>← Previous</button>
|
|
<span class="page-info">Page ${currentPage} of ${pageCount}</span>
|
|
<button class="next-page" ${currentPage === pageCount ? 'disabled' : ''}>Next →</button>
|
|
`;
|
|
|
|
controls.querySelector('.prev-page')?.addEventListener('click', () => {
|
|
if (currentPage > 1) {
|
|
currentPage--;
|
|
goToPage(container, currentPage);
|
|
}
|
|
});
|
|
|
|
controls.querySelector('.next-page')?.addEventListener('click', () => {
|
|
if (currentPage < pageCount) {
|
|
currentPage++;
|
|
goToPage(container, currentPage);
|
|
}
|
|
});
|
|
|
|
container.appendChild(controls);
|
|
|
|
return { currentMode: 'paginated', currentPage };
|
|
}
|
|
|
|
function goToPage(container: HTMLElement, pageNumber: number): void {
|
|
const content = container.querySelector('.ebook-content') as HTMLElement;
|
|
if (!content) return;
|
|
|
|
const pageHeight = content.clientHeight;
|
|
const scrollTop = (pageNumber - 1) * pageHeight;
|
|
|
|
content.scrollTo({
|
|
top: scrollTop,
|
|
behavior: 'smooth'
|
|
});
|
|
|
|
const pageInfo = container.querySelector('.page-info');
|
|
if (pageInfo) {
|
|
pageInfo.textContent = `Page ${pageNumber} of ${getTotalPageCount(container)}`;
|
|
}
|
|
}
|
|
|
|
function getTotalPageCount(container: HTMLElement): number {
|
|
const content = container.querySelector('.ebook-content') as HTMLElement;
|
|
if (!content) return 1;
|
|
|
|
const totalHeight = content.scrollHeight;
|
|
const pageHeight = content.clientHeight;
|
|
|
|
return Math.ceil(totalHeight / pageHeight);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. PDF Reader Implementation
|
|
|
|
### 6.1 PDF.js Integration (Procedural)
|
|
|
|
**File:** `web/src/reader/pdf/pdfjs-wrapper.ts`
|
|
|
|
```typescript
|
|
// Mozilla pdf.js integration for PDF rendering
|
|
// Procedural style: Functions, not classes
|
|
|
|
import * as pdfjsLib from 'pdfjs-dist';
|
|
|
|
// ============================================================
|
|
// PDF.js Configuration
|
|
// ============================================================
|
|
|
|
export function configurePDFJS(): void {
|
|
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.mjs';
|
|
pdfjsLib.GlobalWorkerOptions.standardFontDataUrl = '/static/standard_fonts/';
|
|
pdfjsLib.GlobalWorkerOptions.cMapUrl = '/static/cmaps/';
|
|
pdfjsLib.GlobalWorkerOptions.cMapPacked = true;
|
|
}
|
|
|
|
// ============================================================
|
|
// PDF Document State
|
|
// ============================================================
|
|
|
|
interface PDFDocumentState {
|
|
doc: pdfjsLib.PDFDocumentProxy | null;
|
|
pages: Map<number, pdfjsLib.PDFPageProxy>;
|
|
metadata: PDFMetadata | null;
|
|
}
|
|
|
|
interface PDFMetadata {
|
|
title: string;
|
|
author: string;
|
|
subject?: string;
|
|
keywords?: string;
|
|
creator?: string;
|
|
producer?: string;
|
|
creationDate?: Date;
|
|
modificationDate?: Date;
|
|
pageCount: number;
|
|
}
|
|
|
|
let pdfState: PDFDocumentState = {
|
|
doc: null,
|
|
pages: new Map(),
|
|
metadata: null,
|
|
};
|
|
|
|
// ============================================================
|
|
// Document Loading
|
|
// ============================================================
|
|
|
|
export async function loadPDFDocument(pdfBlob: Blob): Promise<PDFMetadata> {
|
|
// Cleanup previous document
|
|
unloadPDFDocument();
|
|
|
|
const arrayBuffer = await pdfBlob.arrayBuffer();
|
|
const loadingTask = pdfjsLib.getDocument({
|
|
data: arrayBuffer,
|
|
});
|
|
|
|
pdfState.doc = await loadingTask.promise;
|
|
|
|
// Extract metadata
|
|
const metadata = await pdfState.doc.getMetadata().catch(() => null);
|
|
const info = metadata?.info || {};
|
|
|
|
pdfState.metadata = {
|
|
title: info.Title || 'Untitled',
|
|
author: info.Author || 'Unknown',
|
|
subject: info.Subject,
|
|
keywords: info.Keywords,
|
|
creator: info.Creator,
|
|
producer: info.Producer,
|
|
creationDate: info.CreationDate ? new Date(info.CreationDate) : undefined,
|
|
modificationDate: info.ModDate ? new Date(info.ModDate) : undefined,
|
|
pageCount: pdfState.doc.numPages,
|
|
};
|
|
|
|
return pdfState.metadata;
|
|
}
|
|
|
|
export async function getPDFPage(pageNumber: number): Promise<pdfjsLib.PDFPageProxy> {
|
|
if (!pdfState.doc) {
|
|
throw new Error('PDF document not loaded');
|
|
}
|
|
|
|
// Check cache
|
|
if (pdfState.pages.has(pageNumber)) {
|
|
return pdfState.pages.get(pageNumber)!;
|
|
}
|
|
|
|
// Load page
|
|
const page = await pdfState.doc.getPage(pageNumber);
|
|
pdfState.pages.set(pageNumber, page);
|
|
|
|
return page;
|
|
}
|
|
|
|
export async function getPDFPageText(pageNumber: number): Promise<any> {
|
|
const page = await getPDFPage(pageNumber);
|
|
return await page.getTextContent();
|
|
}
|
|
|
|
export function getPDFMetadata(): PDFMetadata | null {
|
|
return pdfState.metadata;
|
|
}
|
|
|
|
export function getPDFPageCount(): number {
|
|
return pdfState.doc?.numPages || 0;
|
|
}
|
|
|
|
export function unloadPDFDocument(): void {
|
|
pdfState.pages.clear();
|
|
pdfState.doc = null;
|
|
pdfState.metadata = null;
|
|
}
|
|
|
|
export function unloadPDFPage(pageNumber: number): void {
|
|
pdfState.pages.delete(pageNumber);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 6.2 Text Layer Renderer (Procedural)
|
|
|
|
**File:** `web/src/reader/pdf/text-layer-renderer.ts`
|
|
|
|
```typescript
|
|
// Text layer rendering for PDF text selection and highlighting
|
|
// Procedural style: Functions, not classes
|
|
|
|
// ============================================================
|
|
// Render Functions
|
|
// ============================================================
|
|
|
|
export function renderTextLayer(
|
|
container: HTMLElement,
|
|
viewport: any,
|
|
textContent: any,
|
|
config: TextLayerConfig
|
|
): void {
|
|
// Clear container
|
|
container.innerHTML = '';
|
|
|
|
// Apply styles
|
|
applyTextLayerStyles(container, config);
|
|
|
|
// Render text items
|
|
const { items } = textContent;
|
|
|
|
items.forEach((item: any, index: number) => {
|
|
if (typeof item === 'string') return;
|
|
|
|
const textDiv = createTextDiv(item, viewport, index);
|
|
container.appendChild(textDiv);
|
|
});
|
|
}
|
|
|
|
function createTextDiv(item: any, viewport: any, index: number): HTMLElement {
|
|
const div = document.createElement('div');
|
|
div.className = 'pdf-text-layer-text';
|
|
div.textContent = item.str;
|
|
div.dataset.index = index.toString();
|
|
|
|
// Position the text div
|
|
const tx = pdfjsLib.Util.transform(
|
|
viewport.transform,
|
|
item.transform
|
|
);
|
|
|
|
const fontSize = Math.sqrt((tx[0] * tx[0]) + (tx[1] * tx[1]));
|
|
|
|
div.style.left = `${tx[4]}px`;
|
|
div.style.top = `${tx[5] - fontSize}px`;
|
|
div.style.fontSize = `${fontSize}px`;
|
|
div.style.fontFamily = item.fontName || 'sans-serif';
|
|
|
|
// Handle text direction
|
|
if (item.dir === 'ttb') {
|
|
div.style.writingMode = 'vertical-rl';
|
|
}
|
|
|
|
return div;
|
|
}
|
|
|
|
interface TextLayerConfig {
|
|
theme: 'light' | 'sepia' | 'dark' | 'night' | 'high-contrast';
|
|
}
|
|
|
|
function applyTextLayerStyles(container: HTMLElement, config: TextLayerConfig): void {
|
|
const style = document.createElement('style');
|
|
style.textContent = getTextLayerCSS(config.theme);
|
|
container.appendChild(style);
|
|
}
|
|
|
|
function getTextLayerCSS(theme: string): string {
|
|
const colors = getThemeColors(theme);
|
|
|
|
return `
|
|
.pdf-text-layer {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
overflow: hidden;
|
|
opacity: 1;
|
|
line-height: 1;
|
|
-moz-user-select: none;
|
|
-webkit-user-select: none;
|
|
-ms-user-select: none;
|
|
user-select: none;
|
|
}
|
|
|
|
.pdf-text-layer-text {
|
|
position: absolute;
|
|
white-space: pre;
|
|
cursor: text;
|
|
transform-origin: 0% 0%;
|
|
color: transparent;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.pdf-text-layer-text::selection {
|
|
background: ${colors.highlight};
|
|
color: transparent;
|
|
}
|
|
|
|
.pdf-text-layer-text::-moz-selection {
|
|
background: ${colors.highlight};
|
|
color: transparent;
|
|
}
|
|
|
|
.pdf-highlight-overlay {
|
|
position: absolute;
|
|
background-color: ${colors.highlight};
|
|
mix-blend-mode: multiply;
|
|
pointer-events: none;
|
|
}
|
|
`;
|
|
}
|
|
|
|
function getThemeColors(theme: string): { highlight: string } {
|
|
const themes: Record<string, { highlight: string }> = {
|
|
'light': { highlight: 'rgba(255, 255, 0, 0.3)' },
|
|
'sepia': { highlight: 'rgba(255, 200, 0, 0.4)' },
|
|
'dark': { highlight: 'rgba(255, 255, 0, 0.3)' },
|
|
'night': { highlight: 'rgba(100, 150, 255, 0.3)' },
|
|
'high-contrast': { highlight: 'rgba(255, 255, 0, 0.5)' }
|
|
};
|
|
|
|
return themes[theme] || themes['dark'];
|
|
}
|
|
|
|
// ============================================================
|
|
// Selection Functions
|
|
// ============================================================
|
|
|
|
export function getPDFTextSelection(): { text: string; range: Range } | null {
|
|
const selection = window.getSelection();
|
|
if (!selection || selection.rangeCount === 0) return null;
|
|
|
|
const range = selection.getRangeAt(0);
|
|
const text = range.toString();
|
|
|
|
if (!text) return null;
|
|
|
|
return { text, range };
|
|
}
|
|
|
|
export function getPDFSelectionRects(): DOMRect[] {
|
|
const selection = window.getSelection();
|
|
if (!selection || selection.rangeCount === 0) return [];
|
|
|
|
const rects: DOMRect[] = [];
|
|
const range = selection.getRangeAt(0);
|
|
|
|
for (const rect of range.getClientRects()) {
|
|
rects.push(rect);
|
|
}
|
|
|
|
return rects;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 6.3 Annotation Layer (Procedural)
|
|
|
|
**File:** `web/src/reader/pdf/annotation-layer.ts`
|
|
|
|
```typescript
|
|
// Annotation layer for rendering highlights and notes on PDFs
|
|
// Procedural style: Functions, not classes
|
|
|
|
interface PDFHighlight {
|
|
id: string;
|
|
pageNumber: number;
|
|
rects: DOMRect[];
|
|
text: string;
|
|
color: string;
|
|
noteId?: string;
|
|
}
|
|
|
|
const highlights = new Map<string, HTMLElement>();
|
|
|
|
export function renderPDFHighlights(
|
|
container: HTMLElement,
|
|
highlightList: PDFHighlight[]
|
|
): void {
|
|
// Clear existing highlights
|
|
clearPDFHighlights(container);
|
|
|
|
for (const highlight of highlightList) {
|
|
renderSinglePDFHighlight(container, highlight);
|
|
}
|
|
}
|
|
|
|
function renderSinglePDFHighlight(container: HTMLElement, highlight: PDFHighlight): void {
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'pdf-highlight-annotation';
|
|
overlay.dataset.highlightId = highlight.id;
|
|
overlay.style.backgroundColor = parseColor(highlight.color);
|
|
|
|
// Position highlight rectangles
|
|
for (const rect of highlight.rects) {
|
|
const rectDiv = document.createElement('div');
|
|
rectDiv.className = 'pdf-highlight-rect';
|
|
rectDiv.style.left = `${rect.left}px`;
|
|
rectDiv.style.top = `${rect.top}px`;
|
|
rectDiv.style.width = `${rect.width}px`;
|
|
rectDiv.style.height = `${rect.height}px`;
|
|
|
|
overlay.appendChild(rectDiv);
|
|
}
|
|
|
|
// Add click handler for note popup
|
|
if (highlight.noteId) {
|
|
overlay.style.cursor = 'pointer';
|
|
overlay.addEventListener('click', () => {
|
|
showNotePopup(highlight);
|
|
});
|
|
}
|
|
|
|
// Add hover effect
|
|
overlay.addEventListener('mouseenter', () => {
|
|
overlay.style.opacity = '0.8';
|
|
});
|
|
|
|
overlay.addEventListener('mouseleave', () => {
|
|
overlay.style.opacity = '0.5';
|
|
});
|
|
|
|
container.appendChild(overlay);
|
|
highlights.set(highlight.id, overlay);
|
|
}
|
|
|
|
function parseColor(color: string): string {
|
|
if (color.startsWith('#')) {
|
|
const hex = color.slice(1);
|
|
const r = parseInt(hex.slice(0, 2), 16);
|
|
const g = parseInt(hex.slice(2, 4), 16);
|
|
const b = parseInt(hex.slice(4, 6), 16);
|
|
return `rgba(${r}, ${g}, ${b}, 0.4)`;
|
|
}
|
|
|
|
return color;
|
|
}
|
|
|
|
function showNotePopup(highlight: PDFHighlight): void {
|
|
console.log('Show note for highlight:', highlight.id);
|
|
}
|
|
|
|
export function clearPDFHighlights(container: HTMLElement): void {
|
|
highlights.forEach(element => element.remove());
|
|
highlights.clear();
|
|
}
|
|
|
|
export function removePDFHighlight(highlightId: string): void {
|
|
const element = highlights.get(highlightId);
|
|
if (element) {
|
|
element.remove();
|
|
highlights.delete(highlightId);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 6.4 PDF Navigation (Procedural)
|
|
|
|
**File:** `web/src/reader/pdf/pdf-navigation.ts`
|
|
|
|
```typescript
|
|
// PDF navigation: page turning, zoom, fit modes
|
|
// Procedural style: Functions, not classes
|
|
|
|
type PageFitMode = 'fit-width' | 'fit-page' | 'fit-height' | 'none';
|
|
|
|
interface PDFNavigationState {
|
|
currentPage: number;
|
|
totalPages: number;
|
|
currentScale: number;
|
|
fitMode: PageFitMode;
|
|
scrollContainer: HTMLElement | null;
|
|
}
|
|
|
|
let navState: PDFNavigationState = {
|
|
currentPage: 1,
|
|
totalPages: 0,
|
|
currentScale: 1.0,
|
|
fitMode: 'fit-width',
|
|
scrollContainer: null,
|
|
};
|
|
|
|
// ============================================================
|
|
// Initialization
|
|
// ============================================================
|
|
|
|
export function initializePDFNavigation(
|
|
container: HTMLElement,
|
|
onPageChange: (pageNumber: number) => void,
|
|
onZoomChange: (scale: number) => void
|
|
): void {
|
|
navState.scrollContainer = container.querySelector('.pdf-scroll-container') || container;
|
|
setupPDFKeyboardNav(onPageChange);
|
|
setupPDFScrollTracking(onPageChange);
|
|
}
|
|
|
|
export function setPDFTotalPages(totalPages: number): void {
|
|
navState.totalPages = totalPages;
|
|
}
|
|
|
|
// ============================================================
|
|
// Page Navigation
|
|
// ============================================================
|
|
|
|
export function goToPDFPage(pageNumber: number): void {
|
|
if (pageNumber < 1 || pageNumber > navState.totalPages) return;
|
|
|
|
navState.currentPage = pageNumber;
|
|
|
|
const callback = (window as any).pdfOnPageChange;
|
|
if (callback) callback(pageNumber);
|
|
|
|
scrollToPDFPage(pageNumber);
|
|
}
|
|
|
|
export function nextPDFPage(): void {
|
|
if (navState.currentPage < navState.totalPages) {
|
|
goToPDFPage(navState.currentPage + 1);
|
|
}
|
|
}
|
|
|
|
export function previousPDFPage(): void {
|
|
if (navState.currentPage > 1) {
|
|
goToPDFPage(navState.currentPage - 1);
|
|
}
|
|
}
|
|
|
|
function scrollToPDFPage(pageNumber: number): void {
|
|
if (!navState.scrollContainer) return;
|
|
|
|
const pageElement = navState.scrollContainer.querySelector(`[data-page-number="${pageNumber}"]`);
|
|
if (pageElement) {
|
|
pageElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Zoom Controls
|
|
// ============================================================
|
|
|
|
export function setPDFZoom(scale: number): void {
|
|
navState.currentScale = scale;
|
|
navState.fitMode = 'none';
|
|
|
|
const callback = (window as any).pdfOnZoomChange;
|
|
if (callback) callback(scale);
|
|
|
|
updatePDFZoom();
|
|
}
|
|
|
|
export function setPDFFitMode(mode: PageFitMode): void {
|
|
navState.fitMode = mode;
|
|
updatePDFZoom();
|
|
}
|
|
|
|
export function zoomPDFIn(): void {
|
|
setPDFZoom(navState.currentScale * 1.2);
|
|
}
|
|
|
|
export function zoomPDFOut(): void {
|
|
setPDFZoom(navState.currentScale / 1.2);
|
|
}
|
|
|
|
function updatePDFZoom(): void {
|
|
if (!navState.scrollContainer) return;
|
|
|
|
const pages = navState.scrollContainer.querySelectorAll('.pdf-page-container');
|
|
pages.forEach((page: Element) => {
|
|
(page as HTMLElement).style.transform = `scale(${navState.currentScale})`;
|
|
(page as HTMLElement).style.transformOrigin = 'top center';
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Keyboard Navigation
|
|
// ============================================================
|
|
|
|
function setupPDFKeyboardNav(onPageChange: (pageNumber: number) => void): void {
|
|
document.addEventListener('keydown', handlePDFKeyDown);
|
|
}
|
|
|
|
function handlePDFKeyDown(e: KeyboardEvent): void {
|
|
switch (e.key) {
|
|
case 'ArrowRight':
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
nextPDFPage();
|
|
break;
|
|
case 'ArrowLeft':
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
previousPDFPage();
|
|
break;
|
|
case 'Home':
|
|
e.preventDefault();
|
|
goToPDFPage(1);
|
|
break;
|
|
case 'End':
|
|
e.preventDefault();
|
|
goToPDFPage(navState.totalPages);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Scroll Tracking
|
|
// ============================================================
|
|
|
|
function setupPDFScrollTracking(onPageChange: (pageNumber: number) => void): void {
|
|
if (!navState.scrollContainer) return;
|
|
|
|
let scrollTimeout: NodeJS.Timeout;
|
|
|
|
navState.scrollContainer.addEventListener('scroll', () => {
|
|
clearTimeout(scrollTimeout);
|
|
|
|
scrollTimeout = setTimeout(() => {
|
|
updateCurrentPageFromScroll(onPageChange);
|
|
}, 100);
|
|
});
|
|
}
|
|
|
|
function updateCurrentPageFromScroll(onPageChange: (pageNumber: number) => void): void {
|
|
if (!navState.scrollContainer) return;
|
|
|
|
const scrollTop = navState.scrollContainer.scrollTop;
|
|
const containerHeight = navState.scrollContainer.clientHeight;
|
|
|
|
const pages = navState.scrollContainer.querySelectorAll('[data-page-number]');
|
|
let maxVisibility = 0;
|
|
let mostVisiblePage = navState.currentPage;
|
|
|
|
pages.forEach((page) => {
|
|
const element = page as HTMLElement;
|
|
const pageTop = element.offsetTop;
|
|
const pageBottom = pageTop + element.offsetHeight;
|
|
|
|
const visibleTop = Math.max(scrollTop, pageTop);
|
|
const visibleBottom = Math.min(scrollTop + containerHeight, pageBottom);
|
|
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
|
|
|
|
if (visibleHeight > maxVisibility) {
|
|
maxVisibility = visibleHeight;
|
|
mostVisiblePage = parseInt(element.dataset.pageNumber || '1');
|
|
}
|
|
});
|
|
|
|
if (mostVisiblePage !== navState.currentPage) {
|
|
navState.currentPage = mostVisiblePage;
|
|
onPageChange(mostVisiblePage);
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Getters
|
|
// ============================================================
|
|
|
|
export function getCurrentPDFPage(): number {
|
|
return navState.currentPage;
|
|
}
|
|
|
|
export function getTotalPDFPages(): number {
|
|
return navState.totalPages;
|
|
}
|
|
|
|
export function getPDFScale(): number {
|
|
return navState.currentScale;
|
|
}
|
|
```
|
|
|
|
### 6.5 PDF Search
|
|
|
|
**File:** `web/src/reader/pdf/pdf-search.ts`
|
|
|
|
```typescript
|
|
// Full-text search within PDF documents
|
|
|
|
import { PDFDocumentProxy } from 'pdfjs-dist';
|
|
|
|
interface SearchResult {
|
|
pageNumber: number;
|
|
text: string;
|
|
index: number;
|
|
context: string;
|
|
}
|
|
|
|
// Full-text search within PDF documents
|
|
// Procedural implementation (no OOP)
|
|
|
|
interface SearchResult {
|
|
pageNumber: number;
|
|
text: string;
|
|
index: number;
|
|
context: string;
|
|
}
|
|
|
|
interface PDFSearchState {
|
|
doc: PDFDocumentProxy | null;
|
|
searchResults: SearchResult[];
|
|
currentResultIndex: number;
|
|
}
|
|
|
|
async function initializePDFSearch(doc: PDFDocumentProxy): Promise<PDFSearchState> {
|
|
return {
|
|
doc,
|
|
searchResults: [],
|
|
currentResultIndex: 0
|
|
};
|
|
}
|
|
|
|
async function searchPDF(state: PDFSearchState, query: string): Promise<PDFSearchState> {
|
|
if (!state.doc) return state;
|
|
|
|
const searchResults: SearchResult[] = [];
|
|
const lowerQuery = query.toLowerCase();
|
|
|
|
for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) {
|
|
const page = await state.doc.getPage(pageNum);
|
|
const textContent = await page.getTextContent();
|
|
|
|
let fullText = '';
|
|
const textItems = textContent.items.map(item => {
|
|
if (typeof item === 'string') return '';
|
|
fullText += item.str;
|
|
return item.str;
|
|
});
|
|
|
|
const pageText = textItems.join(' ');
|
|
const matches = findSearchMatches(pageText, lowerQuery, pageNum);
|
|
|
|
searchResults.push(...matches);
|
|
}
|
|
|
|
return { ...state, searchResults };
|
|
}
|
|
|
|
function findSearchMatches(
|
|
text: string,
|
|
query: string,
|
|
pageNumber: number
|
|
): SearchResult[] {
|
|
const matches: SearchResult[] = [];
|
|
const lowerText = text.toLowerCase();
|
|
let index = 0;
|
|
|
|
while ((index = lowerText.indexOf(query, index)) !== -1) {
|
|
const start = Math.max(0, index - 50);
|
|
const end = Math.min(text.length, index + query.length + 50);
|
|
const context = text.slice(start, end);
|
|
|
|
matches.push({
|
|
pageNumber,
|
|
text: text.slice(index, index + query.length),
|
|
index,
|
|
context
|
|
});
|
|
|
|
index += query.length;
|
|
}
|
|
|
|
return matches;
|
|
}
|
|
|
|
function goToNextSearchResult(state: PDFSearchState): PDFSearchState & { result: SearchResult | null } {
|
|
if (state.searchResults.length === 0) {
|
|
return { ...state, result: null };
|
|
}
|
|
|
|
const newIndex = (state.currentResultIndex + 1) % state.searchResults.length;
|
|
return {
|
|
...state,
|
|
currentResultIndex: newIndex,
|
|
result: state.searchResults[newIndex]
|
|
};
|
|
}
|
|
|
|
function goToPreviousSearchResult(state: PDFSearchState): PDFSearchState & { result: SearchResult | null } {
|
|
if (state.searchResults.length === 0) {
|
|
return { ...state, result: null };
|
|
}
|
|
|
|
const newIndex = (state.currentResultIndex - 1 + state.searchResults.length) % state.searchResults.length;
|
|
return {
|
|
...state,
|
|
currentResultIndex: newIndex,
|
|
result: state.searchResults[newIndex]
|
|
};
|
|
}
|
|
|
|
function getSearchResultCount(state: PDFSearchState): number {
|
|
return state.searchResults.length;
|
|
}
|
|
|
|
function clearSearchResults(state: PDFSearchState): PDFSearchState {
|
|
return {
|
|
...state,
|
|
searchResults: [],
|
|
currentResultIndex: 0
|
|
};
|
|
}
|
|
```
|
|
|
|
### 6.6 Page Cache (Pre-rendering)
|
|
|
|
**File:** `web/src/reader/pdf/page-cache.ts`
|
|
|
|
```typescript
|
|
// 5-page ahead cache for PDF pages
|
|
// Pre-renders canvas and text layer for nearby pages
|
|
|
|
import { PDFPageProxy, PageViewport } from 'pdfjs-dist';
|
|
|
|
interface CachedPage {
|
|
pageNumber: number;
|
|
canvas: HTMLCanvasElement;
|
|
textLayer: HTMLElement;
|
|
viewport: PageViewport;
|
|
timestamp: number;
|
|
}
|
|
|
|
// 5-page ahead cache for PDF pages
|
|
// Procedural implementation (no OOP)
|
|
|
|
interface CachedPage {
|
|
pageNumber: number;
|
|
canvas: HTMLCanvasElement;
|
|
textLayer: HTMLElement;
|
|
viewport: PageViewport;
|
|
timestamp: number;
|
|
}
|
|
|
|
interface PDFPageCacheState {
|
|
cache: Map<number, CachedPage>;
|
|
maxCacheSize: number;
|
|
renderCallbacks: Map<number, Array<() => void>>;
|
|
}
|
|
|
|
function createPDFPageCache(maxCacheSize: number = 5): PDFPageCacheState {
|
|
return {
|
|
cache: new Map(),
|
|
maxCacheSize,
|
|
renderCallbacks: new Map()
|
|
};
|
|
}
|
|
|
|
async function getCachedPage(
|
|
state: PDFPageCacheState,
|
|
pageNumber: number,
|
|
renderFn: (pageNumber: number) => Promise<{ canvas: HTMLCanvasElement; textLayer: HTMLElement; viewport: PageViewport }>
|
|
): Promise<PDFPageCacheState & { page: CachedPage }> {
|
|
const cached = state.cache.get(pageNumber);
|
|
if (cached) {
|
|
cached.timestamp = Date.now();
|
|
return { ...state, page: cached };
|
|
}
|
|
|
|
const { canvas, textLayer, viewport } = await renderFn(pageNumber);
|
|
|
|
const cachedPage: CachedPage = {
|
|
pageNumber,
|
|
canvas,
|
|
textLayer,
|
|
viewport,
|
|
timestamp: Date.now()
|
|
};
|
|
|
|
const newCache = new Map(state.cache);
|
|
newCache.set(pageNumber, cachedPage);
|
|
|
|
const callbacks = state.renderCallbacks.get(pageNumber);
|
|
if (callbacks) {
|
|
callbacks.forEach(cb => cb());
|
|
const newCallbacks = new Map(state.renderCallbacks);
|
|
newCallbacks.delete(pageNumber);
|
|
return { ...state, cache: newCache, renderCallbacks: newCallbacks, page: cachedPage };
|
|
}
|
|
|
|
return { ...state, cache: newCache, page: cachedPage };
|
|
}
|
|
|
|
function preloadPages(
|
|
state: PDFPageCacheState,
|
|
currentPage: number,
|
|
totalPages: number
|
|
): PDFPageCacheState {
|
|
for (let i = 1; i <= state.maxCacheSize; i++) {
|
|
const pageNumber = currentPage + i;
|
|
if (pageNumber <= totalPages && !state.cache.has(pageNumber)) {
|
|
triggerPreload(pageNumber);
|
|
}
|
|
}
|
|
|
|
return state;
|
|
}
|
|
|
|
function triggerPreload(pageNumber: number): void {
|
|
console.log('Preloading page:', pageNumber);
|
|
}
|
|
|
|
function invalidatePage(
|
|
state: PDFPageCacheState,
|
|
pageNumber: number
|
|
): PDFPageCacheState {
|
|
const cached = state.cache.get(pageNumber);
|
|
if (cached) {
|
|
cached.canvas.remove();
|
|
cached.textLayer.remove();
|
|
|
|
const newCache = new Map(state.cache);
|
|
newCache.delete(pageNumber);
|
|
|
|
return { ...state, cache: newCache };
|
|
}
|
|
|
|
return state;
|
|
}
|
|
|
|
function clearPageCache(state: PDFPageCacheState): PDFPageCacheState {
|
|
state.cache.forEach(page => {
|
|
page.canvas.remove();
|
|
page.textLayer.remove();
|
|
});
|
|
|
|
return {
|
|
...state,
|
|
cache: new Map()
|
|
};
|
|
}
|
|
|
|
function onPageRendered(
|
|
state: PDFPageCacheState,
|
|
pageNumber: number,
|
|
callback: () => void
|
|
): PDFPageCacheState {
|
|
const newCallbacks = new Map(state.renderCallbacks);
|
|
|
|
if (!newCallbacks.has(pageNumber)) {
|
|
newCallbacks.set(pageNumber, []);
|
|
}
|
|
|
|
newCallbacks.get(pageNumber)!.push(callback);
|
|
|
|
return { ...state, renderCallbacks: newCallbacks };
|
|
}
|
|
```
|
|
|
|
### 6.7 PDF Text Selection (Uses Backend API)
|
|
|
|
**File:** `web/src/reader/pdf/pdf-text-selection.ts`
|
|
|
|
```typescript
|
|
// PDF text selection - Uses backend API for highlight creation
|
|
// Backend handles all position calculations for PDFs
|
|
// Procedural style: Functions, not classes
|
|
|
|
interface PDFTextSelection {
|
|
pageNumber: number;
|
|
text: string;
|
|
rects: DOMRect[];
|
|
}
|
|
|
|
// ============================================================
|
|
// Get PDF Text Selection
|
|
// ============================================================
|
|
|
|
export function getPDFTextSelection(): PDFTextSelection | null {
|
|
const selection = window.getSelection();
|
|
if (!selection || selection.rangeCount === 0) return null;
|
|
|
|
const range = selection.getRangeAt(0);
|
|
const text = range.toString();
|
|
|
|
if (!text) return null;
|
|
|
|
// Get page number from selection
|
|
const pageElement = range.commonAncestorContainer.closest?.('[data-page-number]');
|
|
const pageNumber = pageElement?.dataset.pageNumber
|
|
? parseInt(pageElement.dataset.pageNumber)
|
|
: getCurrentPDFPage();
|
|
|
|
// Get bounding rectangles
|
|
const rects: DOMRect[] = [];
|
|
for (const rect of range.getClientRects()) {
|
|
rects.push(rect);
|
|
}
|
|
|
|
return {
|
|
pageNumber,
|
|
text,
|
|
rects
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// Create PDF Highlight (Backend Calculates Position)
|
|
// ============================================================
|
|
|
|
export async function createPDFHighlight(
|
|
mediaItemId: string,
|
|
selection: PDFTextSelection,
|
|
color: string
|
|
): Promise<Highlight> {
|
|
const selectionData = {
|
|
selection_text: selection.text,
|
|
page_number: selection.pageNumber,
|
|
rects: selection.rects.map(rect => ({
|
|
x: rect.x,
|
|
y: rect.y,
|
|
width: rect.width,
|
|
height: rect.height
|
|
})),
|
|
color
|
|
};
|
|
|
|
// Send to backend - backend calculates all position formats
|
|
const response = await fetch(`/api/media-items/${mediaItemId}/highlights`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(selectionData)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to create highlight: ${response.statusText}`);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
// ============================================================
|
|
// Load and Render PDF Highlights (Backend Provides Positions)
|
|
// ============================================================
|
|
|
|
export async function loadAndRenderPDFHighlights(
|
|
mediaItemId: string,
|
|
container: HTMLElement
|
|
): Promise<void> {
|
|
const response = await fetch(`/api/media-items/${mediaItemId}/highlights`);
|
|
if (!response.ok) return [];
|
|
|
|
const highlights: Highlight[] = await response.json();
|
|
|
|
for (const highlight of highlights) {
|
|
renderPDFHighlight(container, highlight);
|
|
}
|
|
}
|
|
|
|
function renderPDFHighlight(container: HTMLElement, highlight: Highlight): void {
|
|
// Backend provides position data for PDF highlights
|
|
// Check which position format is available
|
|
|
|
if (highlight.start_position && highlight.start_position.startsWith('pdf:page:')) {
|
|
// Backend calculated page-based position
|
|
renderPDFHighlightByPosition(container, highlight);
|
|
} else if (highlight.percentage_start !== null) {
|
|
// Backend calculated percentage position
|
|
renderPDFHighlightByPercentage(container, highlight);
|
|
}
|
|
}
|
|
|
|
function renderPDFHighlightByPosition(container: HTMLElement, highlight: Highlight): void {
|
|
// Parse position string: "pdf:page:45:offset:123"
|
|
const match = highlight.start_position.match(/pdf:page:(\d+):offset:(\d+)/);
|
|
if (!match) return;
|
|
|
|
const pageNumber = parseInt(match[1], 10);
|
|
const offset = parseInt(match[2], 10);
|
|
|
|
// Find the page element
|
|
const pageElement = container.querySelector(`[data-page-number="${pageNumber}"]`);
|
|
if (!pageElement) return;
|
|
|
|
// Get text content at offset
|
|
const textContent = pageElement.querySelector('.pdf-text-layer')?.textContent;
|
|
if (!textContent) return;
|
|
|
|
// Find the text at this offset
|
|
const textBefore = textContent.substring(0, offset);
|
|
const startChar = textBefore.length;
|
|
const endChar = startChar + (highlight.selection_text?.length || 10);
|
|
|
|
if (startChar < textContent.length && endChar <= textContent.length) {
|
|
applyHighlightToTextContent(
|
|
pageElement as HTMLElement,
|
|
startChar,
|
|
endChar,
|
|
highlight.color
|
|
);
|
|
}
|
|
}
|
|
|
|
function renderPDFHighlightByPercentage(container: HTMLElement, highlight: Highlight): void {
|
|
// Backend provides percentage - estimate position
|
|
const percentage = highlight.percentage_start || 0;
|
|
|
|
// Find spine item closest to this percentage
|
|
const totalPages = container.querySelectorAll('[data-page-number]').length;
|
|
const targetPage = Math.ceil(percentage * totalPages);
|
|
|
|
const pageElement = container.querySelector(`[data-page-number="${targetPage}"]`);
|
|
if (pageElement) {
|
|
// Highlight entire page (coarse-grained)
|
|
applyHighlightStylesToElement(pageElement as HTMLElement, highlight.color);
|
|
}
|
|
}
|
|
|
|
function applyHighlightToTextContent(
|
|
element: HTMLElement,
|
|
startChar: number,
|
|
endChar: number,
|
|
color: string
|
|
): void {
|
|
const text = element.textContent || '';
|
|
const before = text.substring(0, startChar);
|
|
const selection = text.substring(startChar, endChar);
|
|
const after = text.substring(endChar);
|
|
|
|
element.textContent = before + selection + after;
|
|
|
|
// Use a mark to wrap the selected text
|
|
element.innerHTML = `${before}<mark style="background-color: ${addAlphaToColor(color, 0.4)}">${selection}</mark>${after}`;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 6.8 PDF Outline/TOC Navigation
|
|
|
|
**File:** `web/src/reader/pdf/pdf-outline.ts`
|
|
|
|
```typescript
|
|
// PDF outline/TOC navigation
|
|
// Procedural implementation (no OOP)
|
|
|
|
interface PDFOutlineNode {
|
|
id: string;
|
|
title: string;
|
|
destination: number | null;
|
|
pageNumber?: number;
|
|
children: PDFOutlineNode[];
|
|
expanded: boolean;
|
|
}
|
|
|
|
interface PDFOutlineState {
|
|
doc: PDFDocumentProxy | null;
|
|
outline: PDFOutlineNode[];
|
|
flatMap: Map<string, number>;
|
|
}
|
|
|
|
async function initializePDFOutline(doc: PDFDocumentProxy): Promise<PDFOutlineState> {
|
|
const state: PDFOutlineState = {
|
|
doc,
|
|
outline: [],
|
|
flatMap: new Map()
|
|
};
|
|
|
|
return await loadPDFOutline(state);
|
|
}
|
|
|
|
async function loadPDFOutline(state: PDFOutlineState): Promise<PDFOutlineState> {
|
|
if (!state.doc) return state;
|
|
|
|
const pdfOutline = await state.doc.getOutline();
|
|
|
|
if (!pdfOutline || pdfOutline.length === 0) {
|
|
return { ...state, outline: [] };
|
|
}
|
|
|
|
const outline = await parseOutlineNodes(state, pdfOutline);
|
|
|
|
return { ...state, outline };
|
|
}
|
|
|
|
async function parseOutlineNodes(
|
|
state: PDFOutlineState,
|
|
nodes: OutlineTreeNode[]
|
|
): Promise<PDFOutlineNode[]> {
|
|
const result: PDFOutlineNode[] = [];
|
|
|
|
for (const node of nodes) {
|
|
const outlineNode: PDFOutlineNode = {
|
|
id: generateOutlineId(),
|
|
title: node.title,
|
|
destination: null,
|
|
children: [],
|
|
expanded: false
|
|
};
|
|
|
|
if (node.dest) {
|
|
const pageNumber = await resolvePDFDestination(state, node.dest);
|
|
outlineNode.destination = pageNumber;
|
|
outlineNode.pageNumber = pageNumber;
|
|
state.flatMap.set(node.title, pageNumber);
|
|
}
|
|
|
|
if (node.items && node.items.length > 0) {
|
|
outlineNode.children = await parseOutlineNodes(state, node.items);
|
|
}
|
|
|
|
result.push(outlineNode);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function resolvePDFDestination(
|
|
state: PDFOutlineState,
|
|
dest: string | any[]
|
|
): Promise<number> {
|
|
if (!state.doc) return 1;
|
|
|
|
try {
|
|
let explicitDest: any[];
|
|
|
|
if (typeof dest === 'string') {
|
|
const destObj = await state.doc.getDestination(dest);
|
|
if (!destObj) return 1;
|
|
explicitDest = destObj;
|
|
} else {
|
|
explicitDest = dest;
|
|
}
|
|
|
|
const ref = explicitDest[0];
|
|
|
|
if (typeof ref === 'object' && ref !== null) {
|
|
const pageIndex = await state.doc.getPageIndex(ref);
|
|
return pageIndex + 1;
|
|
} else if (typeof ref === 'number') {
|
|
return ref + 1;
|
|
}
|
|
|
|
return 1;
|
|
} catch (error) {
|
|
console.error('Failed to resolve destination:', dest, error);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
function generateOutlineId(): string {
|
|
return `outline-${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
function getOutline(state: PDFOutlineState): PDFOutlineNode[] {
|
|
return state.outline;
|
|
}
|
|
|
|
function getOutlineFlatMap(state: PDFOutlineState): Map<string, number> {
|
|
return state.flatMap;
|
|
}
|
|
|
|
function getCurrentChapter(
|
|
state: PDFOutlineState,
|
|
pageNumber: number
|
|
): PDFOutlineNode | null {
|
|
return findChapterForPage(state.outline, pageNumber);
|
|
}
|
|
|
|
function findChapterForPage(
|
|
nodes: PDFOutlineNode[],
|
|
pageNumber: number
|
|
): PDFOutlineNode | null {
|
|
for (const node of nodes) {
|
|
if (node.pageNumber && node.pageNumber <= pageNumber) {
|
|
if (node.children.length > 0) {
|
|
const childMatch = findChapterForPage(node.children, pageNumber);
|
|
if (childMatch) return childMatch;
|
|
}
|
|
return node;
|
|
}
|
|
|
|
if (node.children.length > 0) {
|
|
const childMatch = findChapterForPage(node.children, pageNumber);
|
|
if (childMatch) return childMatch;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function toggleOutlineNode(
|
|
state: PDFOutlineState,
|
|
nodeId: string
|
|
): PDFOutlineState {
|
|
const updateNode = (nodes: PDFOutlineNode[]): PDFOutlineNode[] => {
|
|
return nodes.map(node => {
|
|
if (node.id === nodeId) {
|
|
return { ...node, expanded: !node.expanded };
|
|
}
|
|
if (node.children.length > 0) {
|
|
return { ...node, children: updateNode(node.children) };
|
|
}
|
|
return node;
|
|
});
|
|
};
|
|
|
|
return { ...state, outline: updateNode(state.outline) };
|
|
}
|
|
|
|
function findOutlineNode(
|
|
nodes: PDFOutlineNode[],
|
|
id: string
|
|
): PDFOutlineNode | null {
|
|
for (const node of nodes) {
|
|
if (node.id === id) return node;
|
|
if (node.children.length > 0) {
|
|
const found = findOutlineNode(node.children, id);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
```
|
|
|
|
### 6.9 PDF Bookmarks
|
|
|
|
**File:** `web/src/reader/pdf/pdf-bookmarks.ts`
|
|
|
|
```typescript
|
|
// Custom bookmarks for PDF pages (saved in database)
|
|
// Procedural implementation (no OOP)
|
|
|
|
interface MediaBookmark {
|
|
id: string;
|
|
mediaItemId: string;
|
|
userId: string;
|
|
pageNumber: number;
|
|
title: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
interface MediaBookmarksState {
|
|
mediaItemId: string;
|
|
bookmarks: MediaBookmark[];
|
|
}
|
|
|
|
function createMediaBookmarks(mediaItemId: string): MediaBookmarksState {
|
|
return {
|
|
mediaItemId,
|
|
bookmarks: []
|
|
};
|
|
}
|
|
|
|
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');
|
|
|
|
const data = await response.json();
|
|
return { ...state, bookmarks: data.bookmarks || [] };
|
|
} catch (error) {
|
|
console.error('Failed to load bookmarks:', error);
|
|
return { ...state, bookmarks: [] };
|
|
}
|
|
}
|
|
|
|
async function addMediaBookmark(
|
|
state: MediaBookmarksState,
|
|
pageNumber: number,
|
|
title?: string
|
|
): Promise<MediaBookmarksState & { bookmark: MediaBookmark }> {
|
|
const bookmark: MediaBookmark = {
|
|
id: crypto.randomUUID(),
|
|
mediaItemId: state.mediaItemId,
|
|
userId: '',
|
|
pageNumber,
|
|
title: title || `Page ${pageNumber}`,
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
page_number: pageNumber,
|
|
title: bookmark.title,
|
|
position: `pdf:page:${pageNumber}`
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to create bookmark');
|
|
|
|
const created = await response.json();
|
|
|
|
return {
|
|
...state,
|
|
bookmarks: [...state.bookmarks, created],
|
|
bookmark: created
|
|
};
|
|
} catch (error) {
|
|
console.error('Failed to add bookmark:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function removeMediaBookmark(
|
|
state: MediaBookmarksState,
|
|
bookmarkId: string
|
|
): Promise<MediaBookmarksState> {
|
|
try {
|
|
const response = await fetch(`/api/media-items/${state.mediaItemId}/bookmarks/${bookmarkId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to remove bookmark');
|
|
|
|
return {
|
|
...state,
|
|
bookmarks: state.bookmarks.filter(b => b.id !== bookmarkId)
|
|
};
|
|
} catch (error) {
|
|
console.error('Failed to remove bookmark:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function getMediaBookmarks(state: MediaBookmarksState): MediaBookmark[] {
|
|
return [...state.bookmarks].sort((a, b) => a.pageNumber - b.pageNumber);
|
|
}
|
|
|
|
function hasMediaBookmarkAt(state: MediaBookmarksState, pageNumber: number): boolean {
|
|
return state.bookmarks.some(b => b.pageNumber === pageNumber);
|
|
}
|
|
|
|
function getMediaBookmarkAt(state: MediaBookmarksState, pageNumber: number): MediaBookmark | null {
|
|
return state.bookmarks.find(b => b.pageNumber === pageNumber) || null;
|
|
}
|
|
```
|
|
|
|
### 6.10 PDF Clipboard
|
|
|
|
**File:** `web/src/reader/pdf/pdf-clipboard.ts`
|
|
|
|
```typescript
|
|
// Copy selected text to clipboard (plain text, preserve line breaks)
|
|
// Critical for technical textbooks with code examples
|
|
// Procedural implementation (no OOP)
|
|
|
|
function setupPDFClipboard(container: HTMLElement): void {
|
|
container.addEventListener('copy', (e) => {
|
|
handlePDFCopy(e);
|
|
});
|
|
}
|
|
|
|
function handlePDFCopy(event: ClipboardEvent): void {
|
|
const selection = window.getSelection();
|
|
if (!selection || selection.rangeCount === 0) return;
|
|
|
|
const selectedText = selection.toString();
|
|
|
|
if (!selectedText) return;
|
|
|
|
const plainText = formatPDFPlainText(selectedText);
|
|
|
|
event.clipboardData?.setData('text/plain', plainText);
|
|
|
|
event.preventDefault();
|
|
|
|
showPDFCopyFeedback();
|
|
}
|
|
|
|
function formatPDFPlainText(text: string): string {
|
|
let formatted = text;
|
|
|
|
formatted = formatted.replace(/[ \t]+/g, ' ');
|
|
|
|
formatted = formatted.split('\n').map(line => line.trim()).join('\n');
|
|
|
|
formatted = formatted.replace(/\n{3,}/g, '\n\n');
|
|
|
|
return formatted;
|
|
}
|
|
|
|
async function copyPDFText(text: string): Promise<boolean> {
|
|
const formatted = formatPDFPlainText(text);
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(formatted);
|
|
showPDFCopyFeedback();
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Failed to copy text:', error);
|
|
|
|
const textarea = document.createElement('textarea');
|
|
textarea.value = formatted;
|
|
textarea.style.position = 'fixed';
|
|
textarea.style.opacity = '0';
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
|
|
try {
|
|
const success = document.execCommand('copy');
|
|
if (success) {
|
|
showPDFCopyFeedback();
|
|
}
|
|
return success;
|
|
} catch (fallbackError) {
|
|
console.error('Fallback copy failed:', fallbackError);
|
|
return false;
|
|
} finally {
|
|
document.body.removeChild(textarea);
|
|
}
|
|
}
|
|
}
|
|
|
|
function showPDFCopyFeedback(): void {
|
|
const toast = document.createElement('div');
|
|
toast.className = 'pdf-copy-toast';
|
|
toast.textContent = 'Copied to clipboard';
|
|
toast.style.cssText = `
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
background: var(--accent);
|
|
color: white;
|
|
padding: 8px 16px;
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
z-index: 10000;
|
|
animation: fadeIn 0.2s ease-out;
|
|
`;
|
|
|
|
document.body.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.style.animation = 'fadeOut 0.2s ease-out';
|
|
setTimeout(() => toast.remove(), 200);
|
|
}, 1500);
|
|
}
|
|
```
|
|
|
|
### 6.11 PDF Link Handler
|
|
|
|
**File:** `web/src/reader/pdf/pdf-links.ts`
|
|
|
|
```typescript
|
|
// Handle internal PDF links (cross-references, citations, TOC links)
|
|
// External links open in new tab
|
|
// Procedural implementation (no OOP)
|
|
|
|
interface PDFLink {
|
|
url: string;
|
|
pageNumber?: number;
|
|
bounds: { x: number; y: number; width: number; height: number };
|
|
}
|
|
|
|
interface PDFLinkHandlerState {
|
|
doc: PDFDocumentProxy | null;
|
|
container: HTMLElement;
|
|
onPageNavigate: (pageNumber: number) => void;
|
|
}
|
|
|
|
async function initializePDFLinkHandler(
|
|
container: HTMLElement,
|
|
onPageNavigate: (pageNumber: number) => void,
|
|
doc: PDFDocumentProxy
|
|
): Promise<PDFLinkHandlerState> {
|
|
const state: PDFLinkHandlerState = {
|
|
doc,
|
|
container,
|
|
onPageNavigate
|
|
};
|
|
|
|
await setupPDFLinks(state);
|
|
|
|
return state;
|
|
}
|
|
|
|
async function setupPDFLinks(state: PDFLinkHandlerState): Promise<void> {
|
|
if (!state.doc) return;
|
|
|
|
for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) {
|
|
const page = await state.doc.getPage(pageNum);
|
|
const annotations = await page.getAnnotations();
|
|
|
|
for (const annotation of annotations) {
|
|
if (annotation.subtype === 'Link') {
|
|
createPDFLinkElement(state, annotation, pageNum);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function createPDFLinkElement(
|
|
state: PDFLinkHandlerState,
|
|
annotation: any,
|
|
pageNumber: number
|
|
): void {
|
|
const pageElement = state.container.querySelector(`[data-page-number="${pageNumber}"]`);
|
|
if (!pageElement) return;
|
|
|
|
const link = document.createElement('a');
|
|
link.className = 'pdf-internal-link';
|
|
link.href = 'javascript:void(0)';
|
|
|
|
if (annotation.rect) {
|
|
const rect = annotation.rect;
|
|
link.style.position = 'absolute';
|
|
link.style.left = `${rect[0]}px`;
|
|
link.style.top = `${rect[1]}px`;
|
|
link.style.width = `${rect[2] - rect[0]}px`;
|
|
link.style.height = `${rect[3] - rect[1]}px`;
|
|
link.style.cursor = 'pointer';
|
|
}
|
|
|
|
link.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
handlePDFLinkClick(state, annotation);
|
|
});
|
|
|
|
pageElement.appendChild(link);
|
|
}
|
|
|
|
async function handlePDFLinkClick(
|
|
state: PDFLinkHandlerState,
|
|
annotation: any
|
|
): Promise<void> {
|
|
if (!state.doc) return;
|
|
|
|
if (annotation.url) {
|
|
if (annotation.url.startsWith('http://') || annotation.url.startsWith('https://')) {
|
|
window.open(annotation.url, '_blank', 'noopener,noreferrer');
|
|
} else {
|
|
console.warn('Unhandled URL:', annotation.url);
|
|
}
|
|
} else if (annotation.dest) {
|
|
const pageNumber = await resolvePDFLinkDestination(state, annotation.dest);
|
|
state.onPageNavigate(pageNumber);
|
|
}
|
|
}
|
|
|
|
async function resolvePDFLinkDestination(
|
|
state: PDFLinkHandlerState,
|
|
dest: string | any[]
|
|
): Promise<number> {
|
|
if (!state.doc) return 1;
|
|
|
|
try {
|
|
let explicitDest: any[];
|
|
|
|
if (typeof dest === 'string') {
|
|
const destObj = await state.doc.getDestination(dest);
|
|
if (!destObj) return 1;
|
|
explicitDest = destObj;
|
|
} else {
|
|
explicitDest = dest;
|
|
}
|
|
|
|
const ref = explicitDest[0];
|
|
|
|
if (typeof ref === 'object' && ref !== null) {
|
|
const pageIndex = await state.doc.getPageIndex(ref);
|
|
return pageIndex + 1;
|
|
} else if (typeof ref === 'number') {
|
|
return ref + 1;
|
|
}
|
|
|
|
return 1;
|
|
} catch (error) {
|
|
console.error('Failed to resolve link destination:', error);
|
|
return 1;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 6.12 PDF Dual Page Spread View
|
|
|
|
**File:** `web/src/reader/pdf/pdf-dual-page.ts`
|
|
|
|
```typescript
|
|
// Dual page spread view for PDFs
|
|
// Procedural implementation (no OOP)
|
|
|
|
type DualPageMode = 'single' | 'dual';
|
|
|
|
interface PDFDualPageViewState {
|
|
currentMode: DualPageMode;
|
|
minViewportWidth: number;
|
|
}
|
|
|
|
function createPDFDualPageView(
|
|
container: HTMLElement,
|
|
onModeChange: (mode: DualPageMode) => void
|
|
): PDFDualPageViewState {
|
|
const state: PDFDualPageViewState = {
|
|
currentMode: 'single',
|
|
minViewportWidth: 1200
|
|
};
|
|
|
|
setupResponsiveDualPageToggle(container, state, onModeChange);
|
|
|
|
return state;
|
|
}
|
|
|
|
function setupResponsiveDualPageToggle(
|
|
container: HTMLElement,
|
|
state: PDFDualPageViewState,
|
|
onModeChange: (mode: DualPageMode) => void
|
|
): void {
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
handleDualPageResize(container, state, onModeChange);
|
|
});
|
|
|
|
resizeObserver.observe(container);
|
|
}
|
|
|
|
function handleDualPageResize(
|
|
container: HTMLElement,
|
|
state: PDFDualPageViewState,
|
|
onModeChange: (mode: DualPageMode) => void
|
|
): PDFDualPageViewState {
|
|
const viewportWidth = window.innerWidth;
|
|
|
|
if (viewportWidth >= state.minViewportWidth && state.currentMode === 'single') {
|
|
if (!hasManualDualPageOverride()) {
|
|
return setDualPageMode(container, state, 'dual', false, onModeChange);
|
|
}
|
|
} else if (viewportWidth < state.minViewportWidth && state.currentMode === 'dual') {
|
|
return setDualPageMode(container, state, 'single', false, onModeChange);
|
|
}
|
|
|
|
return state;
|
|
}
|
|
|
|
function setDualPageMode(
|
|
container: HTMLElement,
|
|
state: PDFDualPageViewState,
|
|
mode: DualPageMode,
|
|
manual: boolean,
|
|
onModeChange: (mode: DualPageMode) => void
|
|
): PDFDualPageViewState {
|
|
if (state.currentMode === mode) return state;
|
|
|
|
container.classList.remove('pdf-single-page', 'pdf-dual-page');
|
|
container.classList.add(mode === 'dual' ? 'pdf-dual-page' : 'pdf-single-page');
|
|
|
|
if (manual) {
|
|
setManualDualPageOverride(mode);
|
|
}
|
|
|
|
onModeChange(mode);
|
|
|
|
return { ...state, currentMode: mode };
|
|
}
|
|
|
|
function toggleDualPageMode(
|
|
container: HTMLElement,
|
|
state: PDFDualPageViewState,
|
|
onModeChange: (mode: DualPageMode) => void
|
|
): PDFDualPageViewState {
|
|
const newMode = state.currentMode === 'single' ? 'dual' : 'single';
|
|
return setDualPageMode(container, state, newMode, true, onModeChange);
|
|
}
|
|
|
|
function getDualPagePagePair(
|
|
state: PDFDualPageViewState,
|
|
currentPage: number,
|
|
totalPages: number
|
|
): { left?: number; right: number } {
|
|
if (state.currentMode === 'single') {
|
|
return { right: currentPage };
|
|
}
|
|
|
|
if (currentPage % 2 === 1) {
|
|
return {
|
|
left: currentPage > 1 ? currentPage - 1 : undefined,
|
|
right: currentPage
|
|
};
|
|
} else {
|
|
return {
|
|
left: currentPage,
|
|
right: currentPage < totalPages ? currentPage + 1 : currentPage
|
|
};
|
|
}
|
|
}
|
|
|
|
function hasManualDualPageOverride(): boolean {
|
|
return localStorage.getItem('pdf-dual-page-manual') === 'true';
|
|
}
|
|
|
|
function setManualDualPageOverride(mode: DualPageMode): void {
|
|
localStorage.setItem('pdf-dual-page-manual', 'true');
|
|
localStorage.setItem('pdf-dual-page-mode', mode);
|
|
}
|
|
|
|
function getDualPageStyles(): string {
|
|
return `
|
|
.pdf-dual-page .pdf-page-container {
|
|
display: inline-block;
|
|
vertical-align: top;
|
|
width: 50%;
|
|
}
|
|
|
|
.pdf-dual-page .pdf-scroll-container {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
}
|
|
|
|
.pdf-single-page .pdf-page-container {
|
|
display: block;
|
|
width: 100%;
|
|
}
|
|
`;
|
|
}
|
|
```
|
|
|
|
### 6.13 PDF Mini-Map Navigation
|
|
|
|
**File:** `web/src/reader/pdf/pdf-minimap.ts`
|
|
|
|
```typescript
|
|
// Mini-map navigation for PDF pages
|
|
// Procedural implementation (no OOP)
|
|
|
|
interface PDFMiniMapState {
|
|
miniMap: HTMLElement;
|
|
currentPage: number;
|
|
totalPages: number;
|
|
thumbnails: Map<number, HTMLCanvasElement>;
|
|
onPageNavigate: (pageNumber: number) => void;
|
|
}
|
|
|
|
function createPDFMiniMap(
|
|
container: HTMLElement,
|
|
onPageNavigate: (pageNumber: number) => void
|
|
): PDFMiniMapState {
|
|
const miniMap = createMiniMapElement(container);
|
|
container.appendChild(miniMap);
|
|
|
|
return {
|
|
miniMap,
|
|
currentPage: 1,
|
|
totalPages: 0,
|
|
thumbnails: new Map(),
|
|
onPageNavigate
|
|
};
|
|
}
|
|
|
|
function createMiniMapElement(container: HTMLElement): HTMLElement {
|
|
const miniMap = document.createElement('div');
|
|
miniMap.className = 'pdf-minimap';
|
|
miniMap.innerHTML = `
|
|
<div class="pdf-minimap-header">Pages</div>
|
|
<div class="pdf-minimap-thumbnails"></div>
|
|
<div class="pdf-minimap-indicator"></div>
|
|
`;
|
|
|
|
const style = document.createElement('style');
|
|
style.textContent = getMiniMapStyles();
|
|
miniMap.appendChild(style);
|
|
|
|
return miniMap;
|
|
}
|
|
|
|
async function initializePDFMiniMap(
|
|
state: PDFMiniMapState,
|
|
totalPages: number,
|
|
renderThumbnail: (page: number) => Promise<HTMLCanvasElement>
|
|
): Promise<PDFMiniMapState> {
|
|
const newState = { ...state, totalPages };
|
|
|
|
await generateMiniMapThumbnails(newState, renderThumbnail);
|
|
setupMiniMapEventListeners(newState);
|
|
|
|
return newState;
|
|
}
|
|
|
|
async function generateMiniMapThumbnails(
|
|
state: PDFMiniMapState,
|
|
renderThumbnail: (page: number) => Promise<HTMLCanvasElement>
|
|
): Promise<void> {
|
|
const container = state.miniMap.querySelector('.pdf-minimap-thumbnails') as HTMLElement;
|
|
container.innerHTML = '';
|
|
|
|
for (let page = 1; page <= state.totalPages; page++) {
|
|
try {
|
|
const thumbnail = await renderThumbnail(page);
|
|
thumbnail.className = 'pdf-minimap-thumbnail';
|
|
thumbnail.dataset.pageNumber = page.toString();
|
|
thumbnail.style.width = '80px';
|
|
thumbnail.style.height = 'auto';
|
|
thumbnail.style.cursor = 'pointer';
|
|
thumbnail.style.marginBottom = '4px';
|
|
|
|
container.appendChild(thumbnail);
|
|
state.thumbnails.set(page, thumbnail);
|
|
} catch (error) {
|
|
console.error(`Failed to generate thumbnail for page ${page}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
function setupMiniMapEventListeners(state: PDFMiniMapState): void {
|
|
const container = state.miniMap.querySelector('.pdf-minimap-thumbnails');
|
|
|
|
container?.addEventListener('click', (e) => {
|
|
const thumbnail = (e.target as HTMLElement).closest('.pdf-minimap-thumbnail') as HTMLElement;
|
|
if (thumbnail) {
|
|
const pageNumber = parseInt(thumbnail.dataset.pageNumber || '1');
|
|
state.onPageNavigate(pageNumber);
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateMiniMapCurrentPage(state: PDFMiniMapState, pageNumber: number): PDFMiniMapState {
|
|
const indicator = state.miniMap.querySelector('.pdf-minimap-indicator') as HTMLElement;
|
|
const thumbnail = state.thumbnails.get(pageNumber);
|
|
|
|
if (thumbnail && indicator) {
|
|
const rect = thumbnail.getBoundingClientRect();
|
|
indicator.style.top = `${thumbnail.offsetTop}px`;
|
|
indicator.style.height = `${rect.height}px`;
|
|
}
|
|
|
|
state.thumbnails.forEach((thumb, page) => {
|
|
if (page === pageNumber) {
|
|
thumb.style.outline = '2px solid var(--accent)';
|
|
thumb.style.opacity = '1';
|
|
} else {
|
|
thumb.style.outline = 'none';
|
|
thumb.style.opacity = '0.7';
|
|
}
|
|
});
|
|
|
|
return { ...state, currentPage: pageNumber };
|
|
}
|
|
|
|
function showMiniMap(state: PDFMiniMapState): void {
|
|
state.miniMap.style.display = 'block';
|
|
}
|
|
|
|
function hideMiniMap(state: PDFMiniMapState): void {
|
|
state.miniMap.style.display = 'none';
|
|
}
|
|
|
|
function toggleMiniMap(state: PDFMiniMapState): void {
|
|
const isVisible = state.miniMap.style.display !== 'none';
|
|
state.miniMap.style.display = isVisible ? 'none' : 'block';
|
|
}
|
|
|
|
function getMiniMapStyles(): string {
|
|
return `
|
|
.pdf-minimap {
|
|
position: fixed;
|
|
right: 20px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
width: 100px;
|
|
max-height: 80vh;
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--text-secondary);
|
|
border-radius: 8px;
|
|
padding: 8px;
|
|
overflow-y: auto;
|
|
z-index: 1000;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
|
}
|
|
|
|
.pdf-minimap-header {
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
text-align: center;
|
|
margin-bottom: 8px;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.pdf-minimap-thumbnails {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.pdf-minimap-thumbnail {
|
|
transition: outline 0.2s, opacity 0.2s;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.pdf-minimap-thumbnail:hover {
|
|
opacity: 1 !important;
|
|
outline: 1px solid var(--text-secondary) !important;
|
|
}
|
|
|
|
.pdf-minimap-indicator {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
border-left: 3px solid var(--accent);
|
|
pointer-events: none;
|
|
transition: top 0.3s ease-out;
|
|
}
|
|
`;
|
|
}
|
|
```
|
|
|
|
### 6.14 Navigator Panel (Affinity Image Editor Style)
|
|
|
|
**File:** `web/src/reader/navigator-panel.ts`
|
|
|
|
```typescript
|
|
// Navigator panel - shows full page with draggable viewport box
|
|
// Affinity/Photoshop-style mini-map for page navigation
|
|
// Procedural implementation (no OOP)
|
|
|
|
import { loadSettings } from "./settings-manager";
|
|
|
|
interface NavigatorState {
|
|
panelId: string;
|
|
container: HTMLElement;
|
|
viewport: HTMLElement;
|
|
currentPage: number;
|
|
totalPages: number;
|
|
scale: number;
|
|
contentImage: HTMLImageElement | null;
|
|
isDragging: boolean;
|
|
}
|
|
|
|
function initializeNavigator(containerSelector: string): NavigatorState {
|
|
const container = document.querySelector(containerSelector) as HTMLElement;
|
|
if (!container) throw new Error("Navigator container not found");
|
|
|
|
const viewport = document.createElement('div');
|
|
viewport.className = 'navigator-viewport-box';
|
|
viewport.style.cssText = `
|
|
position: absolute;
|
|
border: 2px solid var(--accent-color, #3b82f6);
|
|
background: rgba(59, 130, 246, 0.1);
|
|
cursor: move;
|
|
z-index: 10;
|
|
`;
|
|
|
|
container.appendChild(viewport);
|
|
|
|
const state: NavigatorState = {
|
|
panelId: 'navigator',
|
|
container,
|
|
viewport,
|
|
currentPage: 1,
|
|
totalPages: 1,
|
|
scale: 0.1,
|
|
contentImage: null,
|
|
isDragging: false
|
|
};
|
|
|
|
setupNavigatorDragHandler(state);
|
|
return state;
|
|
}
|
|
|
|
// Setup draggable viewport box within navigator
|
|
function setupNavigatorDragHandler(state: NavigatorState): void {
|
|
state.viewport.addEventListener('mousedown', (e) => {
|
|
e.preventDefault();
|
|
state.isDragging = true;
|
|
state.viewport.style.cursor = 'grabbing';
|
|
});
|
|
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (!state.isDragging || !state.contentImage) return;
|
|
|
|
const containerRect = state.container.getBoundingClientRect();
|
|
const imgRect = state.contentImage.getBoundingClientRect();
|
|
|
|
// Calculate position relative to scaled image
|
|
const relX = (e.clientX - imgRect.left) / imgRect.width;
|
|
const relY = (e.clientY - imgRect.top) / imgRect.height;
|
|
|
|
// Update main viewer's position (call external handler)
|
|
const mainViewer = document.getElementById('reader-content');
|
|
if (mainViewer) {
|
|
mainViewer.dataset.panX = relX.toString();
|
|
mainViewer.dataset.panY = relY.toString();
|
|
// Dispatch event for main viewer to handle
|
|
mainViewer.dispatchEvent(new CustomEvent('navigator-pan', {
|
|
detail: { x: relX, y: relY }
|
|
}));
|
|
}
|
|
});
|
|
|
|
document.addEventListener('mouseup', () => {
|
|
state.isDragging = false;
|
|
state.viewport.style.cursor = 'move';
|
|
});
|
|
}
|
|
|
|
// Update navigator with current page image
|
|
async function updateNavigatorContent(state: NavigatorState, pageNumber: number): Promise<void> {
|
|
state.currentPage = pageNumber;
|
|
|
|
// Get current page image (from PDF viewer, comic reader, or manga reader)
|
|
const contentArea = document.getElementById('reader-content');
|
|
const img = contentArea?.querySelector('img, canvas') as HTMLImageElement | HTMLCanvasElement | null;
|
|
|
|
if (!img) return;
|
|
|
|
// Create thumbnail version for navigator
|
|
const thumb = document.createElement('img');
|
|
thumb.src = img.src || (img as HTMLCanvasElement).toDataURL();
|
|
thumb.style.cssText = `
|
|
width: 100%;
|
|
height: auto;
|
|
display: block;
|
|
pointer-events: none;
|
|
`;
|
|
|
|
// Clear and populate container
|
|
state.container.innerHTML = '';
|
|
state.container.appendChild(thumb);
|
|
state.contentImage = thumb;
|
|
|
|
// Recreate viewport box
|
|
const viewport = document.createElement('div');
|
|
viewport.className = 'navigator-viewport-box';
|
|
viewport.style.cssText = `
|
|
position: absolute;
|
|
border: 2px solid var(--accent-color, #3b82f6);
|
|
background: rgba(59, 130, 246, 0.1);
|
|
cursor: move;
|
|
z-index: 10;
|
|
width: ${100 / state.scale}%;
|
|
height: ${100 / state.scale}%;
|
|
`;
|
|
state.container.appendChild(viewport);
|
|
state.viewport = viewport;
|
|
|
|
// Re-attach drag handler
|
|
setupNavigatorDragHandler(state);
|
|
|
|
// Calculate viewport size relative to container
|
|
const containerRect = state.container.getBoundingClientRect();
|
|
const viewportWidth = (containerRect.width / img.width) * 100;
|
|
const viewportHeight = (containerRect.height / img.height) * 100;
|
|
|
|
viewport.style.width = `${viewportWidth}%`;
|
|
viewport.style.height = `${viewportHeight}%`;
|
|
}
|
|
|
|
// Handle window resize
|
|
function handleNavigatorResize(state: NavigatorState): void {
|
|
if (state.contentImage) {
|
|
updateNavigatorContent(state, state.currentPage);
|
|
}
|
|
}
|
|
|
|
export { initializeNavigator, updateNavigatorContent, handleNavigatorResize };
|
|
```
|
|
|
|
**File:** `web/src/reader/pdf/pdf-rotation.ts`
|
|
|
|
```typescript
|
|
// Handle rotated/landscape pages in PDFs
|
|
// Procedural implementation (no OOP)
|
|
|
|
interface PDFRotationState {
|
|
rotations: Map<number, number>;
|
|
}
|
|
|
|
function createPDFRotation(): PDFRotationState {
|
|
return {
|
|
rotations: new Map()
|
|
};
|
|
}
|
|
|
|
async function loadPDFPageRotations(
|
|
state: PDFRotationState,
|
|
doc: any
|
|
): Promise<PDFRotationState> {
|
|
const rotations = new Map<number, number>();
|
|
|
|
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
|
|
const page = await doc.getPage(pageNum);
|
|
const viewport = page.getViewport({ scale: 1 });
|
|
const rotation = viewport.rotation;
|
|
|
|
if (rotation !== 0) {
|
|
rotations.set(pageNum, rotation);
|
|
}
|
|
}
|
|
|
|
return { ...state, rotations };
|
|
}
|
|
|
|
function getPDFPageRotation(state: PDFRotationState, pageNumber: number): number {
|
|
return state.rotations.get(pageNumber) || 0;
|
|
}
|
|
|
|
function hasPDFPageRotation(state: PDFRotationState, pageNumber: number): boolean {
|
|
return state.rotations.has(pageNumber);
|
|
}
|
|
|
|
function applyPDFRotation(
|
|
state: PDFRotationState,
|
|
canvas: HTMLCanvasElement,
|
|
pageNumber: number
|
|
): void {
|
|
const rotation = getPDFPageRotation(state, pageNumber);
|
|
|
|
if (rotation === 0) return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
ctx.save();
|
|
ctx.translate(canvas.width / 2, canvas.height / 2);
|
|
ctx.rotate((rotation * Math.PI) / 180);
|
|
ctx.translate(-canvas.width / 2, -canvas.height / 2);
|
|
ctx.restore();
|
|
}
|
|
|
|
function getPDFAdjustedViewport(
|
|
state: PDFRotationState,
|
|
pageNumber: number,
|
|
viewport: any
|
|
): any {
|
|
const rotation = getPDFPageRotation(state, pageNumber);
|
|
|
|
if (rotation === 0 || rotation === 180) {
|
|
return viewport;
|
|
}
|
|
|
|
return {
|
|
...viewport,
|
|
width: viewport.height,
|
|
height: viewport.width
|
|
};
|
|
}
|
|
```
|
|
|
|
### 6.15 PDF Variable Page Sizes
|
|
|
|
**File:** `web/src/reader/pdf/pdf-page-sizes.ts`
|
|
|
|
```typescript
|
|
// Handle PDFs with variable page sizes
|
|
// Procedural implementation (no OOP)
|
|
|
|
interface PageInfo {
|
|
pageNumber: number;
|
|
width: number;
|
|
height: number;
|
|
rotation: number;
|
|
}
|
|
|
|
interface PDFPageSizesState {
|
|
pageSizes: Map<number, PageInfo>;
|
|
defaultSize: { width: number; height: number };
|
|
}
|
|
|
|
function createPDFPageSizes(): PDFPageSizesState {
|
|
return {
|
|
pageSizes: new Map(),
|
|
defaultSize: { width: 595, height: 842 }
|
|
};
|
|
}
|
|
|
|
async function loadPDFPageSizes(
|
|
state: PDFPageSizesState,
|
|
doc: any
|
|
): Promise<PDFPageSizesState> {
|
|
const pageSizes = new Map<number, PageInfo>();
|
|
|
|
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
|
|
const page = await doc.getPage(pageNum);
|
|
const viewport = page.getViewport({ scale: 1 });
|
|
|
|
const pageInfo: PageInfo = {
|
|
pageNumber: pageNum,
|
|
width: viewport.width,
|
|
height: viewport.height,
|
|
rotation: viewport.rotation
|
|
};
|
|
|
|
pageSizes.set(pageNum, pageInfo);
|
|
}
|
|
|
|
return { ...state, pageSizes };
|
|
}
|
|
|
|
function getPDFPageSize(
|
|
state: PDFPageSizesState,
|
|
pageNumber: number
|
|
): PageInfo | null {
|
|
return state.pageSizes.get(pageNumber) || null;
|
|
}
|
|
|
|
function isPDFPageLandscape(
|
|
state: PDFPageSizesState,
|
|
pageNumber: number
|
|
): boolean {
|
|
const size = getPDFPageSize(state, pageNumber);
|
|
if (!size) return false;
|
|
|
|
const effectiveWidth = size.rotation === 90 || size.rotation === 270
|
|
? size.height
|
|
: size.width;
|
|
const effectiveHeight = size.rotation === 90 || size.rotation === 270
|
|
? size.width
|
|
: size.height;
|
|
|
|
return effectiveWidth > effectiveHeight;
|
|
}
|
|
|
|
function getPDFCommonSize(state: PDFPageSizesState): { width: number; height: number } {
|
|
if (state.pageSizes.size === 0) {
|
|
return state.defaultSize;
|
|
}
|
|
|
|
const sizeGroups: Map<string, { width: number; height: number; count: number }> = new Map();
|
|
|
|
state.pageSizes.forEach((size) => {
|
|
const key = getPageSizeKey(size.width, size.height);
|
|
const existing = sizeGroups.get(key);
|
|
|
|
if (existing) {
|
|
existing.count++;
|
|
} else {
|
|
sizeGroups.set(key, { width: size.width, height: size.height, count: 1 });
|
|
}
|
|
});
|
|
|
|
let mostCommon = state.defaultSize;
|
|
let maxCount = 0;
|
|
|
|
sizeGroups.forEach((size) => {
|
|
if (size.count > maxCount) {
|
|
maxCount = size.count;
|
|
mostCommon = { width: size.width, height: size.height };
|
|
}
|
|
});
|
|
|
|
return mostCommon;
|
|
}
|
|
|
|
function getPageSizeKey(width: number, height: number): string {
|
|
const w = Math.round(width / 10) * 10;
|
|
const h = Math.round(height / 10) * 10;
|
|
return `${w}x${h}`;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Panel Detection Implementation
|
|
|
|
### 7.1 Grid-Based Detection (Primary)
|
|
|
|
**File:** `web/src/reader/comic/panel-detector.ts`
|
|
|
|
```typescript
|
|
// Grid-based panel detection (fast, lightweight)
|
|
|
|
interface GridConfig {
|
|
rows: number;
|
|
cols: number;
|
|
}
|
|
|
|
function detectPanelsGrid(
|
|
imageData: ImageData,
|
|
config: GridConfig = { rows: 3, cols: 3 }
|
|
): Panel[] {
|
|
const panels: Panel[] = [];
|
|
const cellWidth = imageData.width / config.cols;
|
|
const cellHeight = imageData.height / config.rows;
|
|
|
|
for (let y = 0; y < config.rows; y++) {
|
|
for (let x = 0; x < config.cols; x++) {
|
|
const cell = extractCell(imageData, x, y, cellWidth, cellHeight);
|
|
|
|
if (!isEmpty(cell)) {
|
|
panels.push({
|
|
id: `panel-${panels.length}`,
|
|
x: (x / config.cols) * 100,
|
|
y: (y / config.rows) * 100,
|
|
width: (1 / config.cols) * 100,
|
|
height: (1 / config.rows) * 100,
|
|
reading_order: panels.length
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return mergeAdjacentPanels(panels);
|
|
}
|
|
|
|
function isEmpty(cellData: ImageData): boolean {
|
|
// Simple edge detection to find empty space
|
|
// Count white/transparent pixels
|
|
let emptyPixels = 0;
|
|
const totalPixels = cellData.width * cellData.height;
|
|
const threshold = 0.95; // 95% empty = empty cell
|
|
|
|
for (let i = 0; i < cellData.data.length; i += 4) {
|
|
const r = cellData.data[i];
|
|
const g = cellData.data[i + 1];
|
|
const b = cellData.data[i + 2];
|
|
const a = cellData.data[i + 3];
|
|
|
|
// Consider white or transparent as empty
|
|
if (a < 10 || (r > 250 && g > 250 && b > 250)) {
|
|
emptyPixels++;
|
|
}
|
|
}
|
|
|
|
return (emptyPixels / totalPixels) > threshold;
|
|
}
|
|
|
|
function mergeAdjacentPanels(panels: Panel[]): Panel[] {
|
|
// Merge panels that are next to each other
|
|
// Simplified algorithm - can be enhanced
|
|
const merged: Panel[] = [];
|
|
const used = new Set<number>();
|
|
|
|
for (let i = 0; i < panels.length; i++) {
|
|
if (used.has(i)) continue;
|
|
|
|
let current = { ...panels[i] };
|
|
used.add(i);
|
|
|
|
// Look for adjacent panels
|
|
for (let j = i + 1; j < panels.length; j++) {
|
|
if (used.has(j)) continue;
|
|
if (isAdjacent(current, panels[j])) {
|
|
current = mergePanels(current, panels[j]);
|
|
used.add(j);
|
|
}
|
|
}
|
|
|
|
merged.push(current);
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
```
|
|
|
|
### 7.2 ML-Based Detection (Enhancement)
|
|
|
|
**File:** `web/src/reader/comic/panel-ml-detector.ts`
|
|
|
|
```typescript
|
|
// ML-based panel detection (optional, lazy-loaded)
|
|
// Uses TensorFlow.js for accurate panel detection
|
|
|
|
let modelLoaded = false;
|
|
let panelModel: any = null;
|
|
|
|
async function loadMLModel(): Promise<void> {
|
|
if (modelLoaded) return;
|
|
|
|
try {
|
|
// Lazy-load TensorFlow.js
|
|
await import('@tensorflow/tfjs');
|
|
|
|
// Load pre-trained model for panel detection
|
|
// Model should be small (~2MB) and fast
|
|
panelModel = await loadModel('/static/models/panel-detection/model.json');
|
|
modelLoaded = true;
|
|
} catch (error) {
|
|
console.error('Failed to load ML model:', error);
|
|
// Fall back to grid-based detection
|
|
}
|
|
}
|
|
|
|
async function detectPanelsML(imageData: ImageData): Promise<Panel[]> {
|
|
if (!modelLoaded) {
|
|
await loadMLModel();
|
|
}
|
|
|
|
if (!panelModel) {
|
|
// Fall back to grid-based
|
|
return detectPanelsGrid(imageData);
|
|
}
|
|
|
|
// Run ML model
|
|
const predictions = await panelModel.detect(imageData);
|
|
|
|
// Convert predictions to Panel format
|
|
return predictions.map((pred: any, index: number) => ({
|
|
id: `ml-panel-${index}`,
|
|
x: pred.bbox.x * 100,
|
|
y: pred.bbox.y * 100,
|
|
width: pred.bbox.width * 100,
|
|
height: pred.bbox.height * 100,
|
|
reading_order: index
|
|
}));
|
|
}
|
|
```
|
|
|
|
### 7.3 Manual Override
|
|
|
|
**File:** `web/src/reader/comic/panel-editor.ts`
|
|
|
|
```typescript
|
|
// Manual panel editor for admins/power users
|
|
|
|
function openPanelEditor(pageNumber: number): void {
|
|
const modal = document.getElementById('panel-editor-modal');
|
|
modal?.classList.remove('hidden');
|
|
|
|
// Load page image
|
|
const canvas = document.getElementById('panel-editor-canvas') as HTMLCanvasElement;
|
|
const ctx = canvas?.getContext('2d');
|
|
|
|
// Load image and draw to canvas
|
|
loadImageForPage(pageNumber).then((image) => {
|
|
canvas!.width = image.width;
|
|
canvas!.height = image.height;
|
|
ctx?.drawImage(image, 0, 0);
|
|
|
|
// Allow user to draw panels
|
|
enablePanelDrawing(canvas!);
|
|
});
|
|
}
|
|
|
|
function enablePanelDrawing(canvas: HTMLCanvasElement): void {
|
|
let isDrawing = false;
|
|
let startX = 0;
|
|
let startY = 0;
|
|
|
|
canvas.addEventListener('mousedown', (e) => {
|
|
isDrawing = true;
|
|
startX = e.offsetX;
|
|
startY = e.offsetY;
|
|
});
|
|
|
|
canvas.addEventListener('mousemove', (e) => {
|
|
if (!isDrawing) return;
|
|
|
|
// Draw selection rectangle
|
|
const ctx = canvas.getContext('2d');
|
|
ctx?.strokeRect(startX, startY, e.offsetX - startX, e.offsetY - startY);
|
|
});
|
|
|
|
canvas.addEventListener('mouseup', (e) => {
|
|
if (!isDrawing) return;
|
|
isDrawing = false;
|
|
|
|
// Save panel
|
|
const panel: Panel = {
|
|
id: `manual-${Date.now()}`,
|
|
x: (startX / canvas.width) * 100,
|
|
y: (startY / canvas.height) * 100,
|
|
width: ((e.offsetX - startX) / canvas.width) * 100,
|
|
height: ((e.offsetY - startY) / canvas.height) * 100,
|
|
reading_order: 0 // Will be set by server
|
|
};
|
|
|
|
saveManualPanel(panel);
|
|
});
|
|
}
|
|
|
|
async function saveManualPanel(panel: Panel): Promise<void> {
|
|
const mediaItemId = document.body.dataset.mediaItemId;
|
|
const pageNumber = getCurrentPageNumber();
|
|
|
|
await apiPut(`/readers/${mediaItemId}/panels/${pageNumber}`, {
|
|
detection_method: 'manual',
|
|
panels: [panel]
|
|
});
|
|
|
|
// Reload with new panels
|
|
loadPage(pageNumber);
|
|
}
|
|
```
|
|
|
|
### 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. Manga Reader Implementation
|
|
|
|
Manga extends the comic reader with specialized reading modes:
|
|
- **RTL (Right-to-Left)** - Traditional Japanese manga reading
|
|
- **Vertical Scroll** - Webtoon/manhwa style (infinite vertical scroll)
|
|
|
|
### 8.1 RTL Navigator (Right-to-Left Reading)
|
|
|
|
**File:** `web/src/reader/manga/rtl-navigator.ts`
|
|
|
|
```typescript
|
|
// Right-to-left navigation for manga
|
|
// Reverses page turn direction and key bindings
|
|
|
|
interface RTLNavigatorState {
|
|
currentPage: number;
|
|
totalPages: number;
|
|
readingDirection: 'rtl' | 'ltr';
|
|
}
|
|
|
|
function createRTLNavigator(totalPages: number): RTLNavigatorState {
|
|
return {
|
|
currentPage: 1,
|
|
totalPages,
|
|
readingDirection: 'rtl'
|
|
};
|
|
}
|
|
|
|
function getNextPage(state: RTLNavigatorState): number {
|
|
// In RTL, "next" page means moving left (decreasing page number)
|
|
if (state.readingDirection === 'rtl') {
|
|
return Math.max(1, state.currentPage - 1);
|
|
}
|
|
return Math.min(state.totalPages, state.currentPage + 1);
|
|
}
|
|
|
|
function getPreviousPage(state: RTLNavigatorState): number {
|
|
// In RTL, "previous" page means moving right (increasing page number)
|
|
if (state.readingDirection === 'rtl') {
|
|
return Math.min(state.totalPages, state.currentPage + 1);
|
|
}
|
|
return Math.max(1, state.currentPage - 1);
|
|
}
|
|
|
|
function navigateToPage(state: RTLNavigatorState, pageNumber: number): RTLNavigatorState {
|
|
return {
|
|
...state,
|
|
currentPage: Math.max(1, Math.min(state.totalPages, pageNumber))
|
|
};
|
|
}
|
|
|
|
function getProgress(state: RTLNavigatorState): { current: number; total: number } {
|
|
return {
|
|
current: state.currentPage,
|
|
total: state.totalPages
|
|
};
|
|
}
|
|
|
|
function getReadingProgressPercentage(state: RTLNavigatorState): number {
|
|
return (state.currentPage / state.totalPages) * 100;
|
|
}
|
|
```
|
|
|
|
### 8.2 Vertical Scroll Mode (Webtoon Style)
|
|
|
|
**File:** `web/src/reader/manga/vertical-scroll-mode.ts`
|
|
|
|
```typescript
|
|
// Vertical scroll mode for webtoons/manhwa
|
|
// Infinite scroll with image loading and lazy rendering
|
|
|
|
interface VerticalScrollState {
|
|
container: HTMLElement;
|
|
loadedPages: Set<number>;
|
|
loadingPages: Set<number>;
|
|
currentPage: number;
|
|
totalPages: number;
|
|
threshold: number; // Distance from bottom to trigger next page load
|
|
mediaItemId: string;
|
|
}
|
|
|
|
function createVerticalScroll(
|
|
container: HTMLElement,
|
|
mediaItemId: string,
|
|
totalPages: number
|
|
): VerticalScrollState {
|
|
const state: VerticalScrollState = {
|
|
container,
|
|
loadedPages: new Set(),
|
|
loadingPages: new Set(),
|
|
currentPage: 1,
|
|
totalPages,
|
|
threshold: 500, // Load next page when 500px from bottom
|
|
mediaItemId
|
|
};
|
|
|
|
// Initial page load
|
|
loadPage(state, 1);
|
|
|
|
// Setup scroll listener
|
|
setupScrollListener(state);
|
|
|
|
return state;
|
|
}
|
|
|
|
async function loadPage(
|
|
state: VerticalScrollState,
|
|
pageNumber: number
|
|
): Promise<void> {
|
|
if (state.loadedPages.has(pageNumber) || state.loadingPages.has(pageNumber)) {
|
|
return;
|
|
}
|
|
|
|
state.loadingPages.add(pageNumber);
|
|
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const response = await fetch(
|
|
`/readers/${state.mediaItemId}/pages/${pageNumber}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load page ${pageNumber}`);
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const imgUrl = URL.createObjectURL(blob);
|
|
|
|
const pageContainer = document.createElement('div');
|
|
pageContainer.className = 'vertical-page';
|
|
pageContainer.dataset.pageNumber = pageNumber.toString();
|
|
|
|
const img = document.createElement('img');
|
|
img.src = imgUrl;
|
|
img.alt = `Page ${pageNumber}`;
|
|
img.loading = 'lazy';
|
|
|
|
pageContainer.appendChild(img);
|
|
state.container.appendChild(pageContainer);
|
|
|
|
state.loadedPages.add(pageNumber);
|
|
state.loadingPages.delete(pageNumber);
|
|
|
|
// Load next pages proactively
|
|
if (pageNumber < state.totalPages) {
|
|
loadPage(state, pageNumber + 1);
|
|
if (pageNumber + 1 < state.totalPages) {
|
|
loadPage(state, pageNumber + 2);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to load page ${pageNumber}:`, error);
|
|
state.loadingPages.delete(pageNumber);
|
|
}
|
|
}
|
|
|
|
function setupScrollListener(state: VerticalScrollState): void {
|
|
let scrollTimeout: number | undefined;
|
|
|
|
state.container.addEventListener('scroll', () => {
|
|
clearTimeout(scrollTimeout);
|
|
scrollTimeout = window.setTimeout(() => {
|
|
checkScrollPosition(state);
|
|
}, 100);
|
|
});
|
|
}
|
|
|
|
function checkScrollPosition(state: VerticalScrollState): void {
|
|
const scrollBottom = state.container.scrollHeight -
|
|
state.container.scrollTop -
|
|
state.container.clientHeight;
|
|
|
|
if (scrollBottom < state.threshold) {
|
|
const lastPage = Math.max(...state.loadedPages);
|
|
if (lastPage < state.totalPages) {
|
|
loadPage(state, lastPage + 1);
|
|
}
|
|
}
|
|
|
|
// Update current page based on scroll position
|
|
const currentPage = getCurrentPageFromScroll(state);
|
|
if (currentPage !== state.currentPage) {
|
|
state.currentPage = currentPage;
|
|
// Dispatch event for progress tracking
|
|
window.dispatchEvent(new CustomEvent('page-change', {
|
|
detail: { page: currentPage }
|
|
}));
|
|
}
|
|
}
|
|
|
|
function getCurrentPageFromScroll(state: VerticalScrollState): number {
|
|
const pages = state.container.querySelectorAll('.vertical-page');
|
|
|
|
for (const page of pages) {
|
|
const rect = page.getBoundingClientRect();
|
|
const containerRect = state.container.getBoundingClientRect();
|
|
|
|
// Page is considered "current" if it's in the middle 50% of viewport
|
|
const pageMiddle = rect.top + rect.height / 2;
|
|
const viewportMiddle = containerRect.top + containerRect.height / 2;
|
|
|
|
if (Math.abs(pageMiddle - viewportMiddle) < containerRect.height / 4) {
|
|
return parseInt(page.dataset.pageNumber || '1');
|
|
}
|
|
}
|
|
|
|
return state.currentPage;
|
|
}
|
|
|
|
function destroyVerticalScroll(state: VerticalScrollState): void {
|
|
// Clean up blob URLs
|
|
const images = state.container.querySelectorAll('img');
|
|
images.forEach(img => {
|
|
const url = img.src;
|
|
if (url.startsWith('blob:')) {
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
});
|
|
|
|
state.container.innerHTML = '';
|
|
state.loadedPages.clear();
|
|
state.loadingPages.clear();
|
|
}
|
|
```
|
|
|
|
### 8.3 Reading Direction Detection
|
|
|
|
**File:** `web/src/reader/manga/reading-direction.ts`
|
|
|
|
```typescript
|
|
// Detect reading direction from metadata or user preference
|
|
|
|
type ReadingDirection = 'auto' | 'ltr' | 'rtl' | 'vertical';
|
|
|
|
interface ReadingDirectionState {
|
|
direction: ReadingDirection;
|
|
detectedDirection: 'ltr' | 'rtl' | 'vertical';
|
|
userPreference: ReadingDirection | null;
|
|
}
|
|
|
|
async function detectReadingDirection(
|
|
metadata: MediaItemMetadata
|
|
): Promise<ReadingDirectionState> {
|
|
// Check user preference first
|
|
const userPreference = await getUserReadingDirectionPreference();
|
|
if (userPreference && userPreference !== 'auto') {
|
|
return {
|
|
direction: userPreference,
|
|
detectedDirection: 'ltr', // Default fallback
|
|
userPreference
|
|
};
|
|
}
|
|
|
|
// Detect from metadata
|
|
const detectedDirection = detectFromMetadata(metadata);
|
|
|
|
return {
|
|
direction: 'auto',
|
|
detectedDirection,
|
|
userPreference: null
|
|
};
|
|
}
|
|
|
|
function detectFromMetadata(metadata: MediaItemMetadata): 'ltr' | 'rtl' | 'vertical' {
|
|
// Check manga_type field from database
|
|
const mangaType = (metadata as any).manga_type;
|
|
if (mangaType === 'yes_and_right_to_left' || mangaType === 'yes') {
|
|
return 'rtl';
|
|
}
|
|
|
|
// Check reading_direction field
|
|
const readingDirection = (metadata as any).reading_direction;
|
|
if (readingDirection === 'rtl' || readingDirection === 'vertical') {
|
|
return readingDirection;
|
|
}
|
|
|
|
// Detect from filename
|
|
const filename = metadata.filePath.toLowerCase();
|
|
if (filename.includes('manga') || filename.includes('manhwa') || filename.includes('webtoon')) {
|
|
return 'vertical';
|
|
}
|
|
|
|
// Default to LTR
|
|
return 'ltr';
|
|
}
|
|
|
|
async function getUserReadingDirectionPreference(): Promise<ReadingDirection | null> {
|
|
const userId = localStorage.getItem('userId');
|
|
if (!userId) return null;
|
|
|
|
try {
|
|
const response = await fetch(`/api/users/${userId}/settings`);
|
|
if (!response.ok) return null;
|
|
|
|
const settings = await response.json();
|
|
return settings.reading_direction || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getEffectiveDirection(state: ReadingDirectionState): 'ltr' | 'rtl' | 'vertical' {
|
|
if (state.direction !== 'auto') {
|
|
return state.direction as 'ltr' | 'rtl' | 'vertical';
|
|
}
|
|
return state.detectedDirection;
|
|
}
|
|
|
|
function shouldUseRTL(state: ReadingDirectionState): boolean {
|
|
return getEffectiveDirection(state) === 'rtl';
|
|
}
|
|
|
|
function shouldUseVerticalScroll(state: ReadingDirectionState): boolean {
|
|
return getEffectiveDirection(state) === 'vertical';
|
|
}
|
|
```
|
|
|
|
### 8.4 Manga Settings Integration
|
|
|
|
**File:** `web/src/reader/manga/settings.ts`
|
|
|
|
```typescript
|
|
// Manga-specific settings integration
|
|
// Extends the common reader settings manager
|
|
|
|
interface MangaSettings {
|
|
readingDirection: 'auto' | 'ltr' | 'rtl' | 'vertical';
|
|
verticalScrollSpeed: 'slow' | 'normal' | 'fast';
|
|
rtlPageTransition: 'slide' | 'fade' | 'none';
|
|
webtoonMode: boolean;
|
|
}
|
|
|
|
async function getMangaSettings(): Promise<MangaSettings> {
|
|
const defaultSettings: MangaSettings = {
|
|
readingDirection: 'auto',
|
|
verticalScrollSpeed: 'normal',
|
|
rtlPageTransition: 'slide',
|
|
webtoonMode: false
|
|
};
|
|
|
|
try {
|
|
const userId = localStorage.getItem('userId');
|
|
const response = await fetch(`/api/users/${userId}/settings`);
|
|
|
|
if (response.ok) {
|
|
const settings = await response.json();
|
|
return { ...defaultSettings, ...settings };
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load manga settings:', error);
|
|
}
|
|
|
|
return defaultSettings;
|
|
}
|
|
|
|
async function updateMangaSettings(
|
|
settings: Partial<MangaSettings>
|
|
): Promise<void> {
|
|
const userId = localStorage.getItem('userId');
|
|
|
|
try {
|
|
const response = await fetch(`/api/users/${userId}/settings`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
},
|
|
body: JSON.stringify(settings)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to update manga settings');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to save manga settings:', error);
|
|
}
|
|
}
|
|
|
|
function applyMangaSettings(settings: MangaSettings): void {
|
|
// Apply reading direction
|
|
document.documentElement.dataset.readingDirection = settings.readingDirection;
|
|
|
|
// Apply vertical scroll speed
|
|
if (settings.verticalScrollSpeed === 'slow') {
|
|
document.documentElement.style.scrollBehavior = 'smooth';
|
|
} else if (settings.verticalScrollSpeed === 'fast') {
|
|
document.documentElement.style.scrollBehavior = 'auto';
|
|
}
|
|
|
|
// Apply RTL page transition
|
|
if (settings.rtlPageTransition !== 'none') {
|
|
document.documentElement.dataset.pageTransition = settings.rtlPageTransition;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 8.5 Manga Page Cache (Shared with Comics)
|
|
|
|
**File:** `web/src/reader/comic/page-cache.ts` (shared for both comics and manga)
|
|
|
|
```typescript
|
|
// Lazy-loading page cache with 5-page ahead prefetch
|
|
// Shared by both comic and manga readers
|
|
|
|
interface PageCacheState {
|
|
cache: Map<number, HTMLImageElement>;
|
|
loading: Set<number>;
|
|
maxAhead: number;
|
|
mediaItemId: string;
|
|
}
|
|
|
|
function createPageCache(mediaItemId: string): PageCacheState {
|
|
return {
|
|
cache: new Map(),
|
|
loading: new Set(),
|
|
maxAhead: 5,
|
|
mediaItemId
|
|
};
|
|
}
|
|
|
|
async function getCachedPage(
|
|
state: PageCacheState,
|
|
pageNumber: number
|
|
): Promise<PageCacheState & { page: HTMLImageElement }> {
|
|
if (state.cache.has(pageNumber)) {
|
|
return { ...state, page: state.cache.get(pageNumber)! };
|
|
}
|
|
|
|
if (state.loading.has(pageNumber)) {
|
|
return new Promise((resolve) => {
|
|
const checkInterval = setInterval(() => {
|
|
if (state.cache.has(pageNumber)) {
|
|
clearInterval(checkInterval);
|
|
resolve({ ...state, page: state.cache.get(pageNumber)! });
|
|
}
|
|
}, 100);
|
|
}) as Promise<PageCacheState & { page: HTMLImageElement}>;
|
|
}
|
|
|
|
const newLoading = new Set(state.loading);
|
|
newLoading.add(pageNumber);
|
|
|
|
const img = await loadComicPage(state, pageNumber);
|
|
|
|
const newCache = new Map(state.cache);
|
|
newCache.set(pageNumber, img);
|
|
newLoading.delete(pageNumber);
|
|
|
|
const newState = { ...state, cache: newCache, loading: newLoading };
|
|
|
|
prefetchPages(newState, pageNumber + 1);
|
|
cleanupPageCache(newState, pageNumber);
|
|
|
|
return { ...state, page: img };
|
|
}
|
|
|
|
async function loadComicPage(
|
|
state: PageCacheState,
|
|
pageNumber: number
|
|
): Promise<HTMLImageElement> {
|
|
const token = localStorage.getItem('token');
|
|
const response = await fetch(
|
|
`/readers/${state.mediaItemId}/pages/${pageNumber}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load page ${pageNumber}`);
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const img = new Image();
|
|
img.src = URL.createObjectURL(blob);
|
|
await new Promise((resolve) => {
|
|
img.onload = resolve;
|
|
});
|
|
return img;
|
|
}
|
|
|
|
function prefetchPages(state: PageCacheState, startPage: number): void {
|
|
for (let i = startPage; i < startPage + state.maxAhead; i++) {
|
|
if (!state.cache.has(i) && !state.loading.has(i)) {
|
|
loadComicPage(state, i).then((img) => {
|
|
state.cache.set(i, img);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function cleanupPageCache(state: PageCacheState, currentPage: number): PageCacheState {
|
|
const keepPages = 10;
|
|
const newCache = new Map(state.cache);
|
|
|
|
for (const [page] of state.cache) {
|
|
if (page < currentPage - keepPages) {
|
|
newCache.delete(page);
|
|
}
|
|
}
|
|
|
|
return { ...state, cache: newCache };
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Offline Support (PWA)
|
|
|
|
### 9.1 Service Worker
|
|
|
|
**File:** `web/static/service-worker.js` (new file)
|
|
|
|
```javascript
|
|
// Service worker for offline reading
|
|
const CACHE_NAME = 'bookhoard-reader-v1';
|
|
const OFFLINE_CACHE = 'bookhoard-offline';
|
|
|
|
// Cache dictionary data for offline use
|
|
self.addEventListener('install', (event) => {
|
|
event.waitUntil(
|
|
caches.open(OFFLINE_CACHE).then((cache) => {
|
|
return cache.addAll([
|
|
'/static/dictionary/en-US.json',
|
|
'/static/dictionary/en-GB.json'
|
|
]);
|
|
})
|
|
);
|
|
});
|
|
|
|
// Cache reader pages
|
|
self.addEventListener('fetch', (event) => {
|
|
const url = new URL(event.request.url);
|
|
|
|
// Cache reader pages
|
|
if (url.pathname.startsWith('/readers/') && url.pathname.includes('/pages/')) {
|
|
event.respondWith(
|
|
caches.open(CACHE_NAME).then((cache) => {
|
|
return cache.match(event.request).then((response) => {
|
|
if (response) {
|
|
return response;
|
|
}
|
|
|
|
// Fetch and cache
|
|
return fetch(event.request).then((networkResponse) => {
|
|
cache.put(event.request, networkResponse.clone());
|
|
return networkResponse;
|
|
});
|
|
});
|
|
})
|
|
);
|
|
}
|
|
|
|
// Cache dictionary lookups
|
|
if (url.pathname.startsWith('/readers/dictionary/')) {
|
|
event.respondWith(
|
|
caches.open(OFFLINE_CACHE).then((cache) => {
|
|
return cache.match(event.request).then((response) => {
|
|
if (response) {
|
|
return response;
|
|
}
|
|
|
|
return fetch(event.request).then((networkResponse) => {
|
|
// Cache dictionary responses
|
|
cache.put(event.request, networkResponse.clone());
|
|
return networkResponse;
|
|
});
|
|
});
|
|
})
|
|
);
|
|
}
|
|
});
|
|
|
|
// Cleanup old caches
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil(
|
|
caches.keys().then((cacheNames) => {
|
|
return Promise.all(
|
|
cacheNames.map((cacheName) => {
|
|
if (cacheName !== CACHE_NAME && cacheName !== OFFLINE_CACHE) {
|
|
return caches.delete(cacheName);
|
|
}
|
|
})
|
|
);
|
|
})
|
|
);
|
|
});
|
|
```
|
|
|
|
### 9.2 PWA Manifest
|
|
|
|
**File:** `web/static/manifest.json` (new file)
|
|
|
|
```json
|
|
{
|
|
"name": "Bookhoard Reader",
|
|
"short_name": "Reader",
|
|
"description": "Offline-capable ebook and comic reader",
|
|
"start_url": "/dashboard",
|
|
"display": "fullscreen",
|
|
"background_color": "#1a1b26",
|
|
"theme_color": "#1a1b26",
|
|
"icons": [
|
|
{
|
|
"src": "/static/icons/icon-192.png",
|
|
"sizes": "192x192",
|
|
"type": "image/png"
|
|
},
|
|
{
|
|
"src": "/static/icons/icon-512.png",
|
|
"sizes": "512x512",
|
|
"type": "image/png"
|
|
}
|
|
],
|
|
"offline_enabled": true
|
|
}
|
|
```
|
|
|
|
### 9.3 Register Service Worker
|
|
|
|
**File:** `web/src/reader/offline-manager.ts` (new file)
|
|
|
|
```typescript
|
|
// Offline manager for PWA functionality
|
|
|
|
export function registerServiceWorker(): void {
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.register('/static/service-worker.js')
|
|
.then((registration) => {
|
|
console.log('Service worker registered:', registration);
|
|
})
|
|
.catch((error) => {
|
|
console.error('Service worker registration failed:', error);
|
|
});
|
|
}
|
|
}
|
|
|
|
export function checkOnlineStatus(): boolean {
|
|
if (typeof navigator !== 'undefined' && navigator.onLine) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Listen for online/offline events
|
|
window.addEventListener('online', () => {
|
|
showToast('Back online', 'success');
|
|
// Sync any pending changes
|
|
syncPendingChanges();
|
|
});
|
|
|
|
window.addEventListener('offline', () => {
|
|
showToast('You are offline. Some features may be limited.', 'warning');
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 10. Dictionary Implementation
|
|
|
|
### 10.1 Dictionary Data
|
|
|
|
**File:** `web/static/dictionary/en-US.json` (new file)
|
|
|
|
Compressed dictionary data with common words. Format:
|
|
|
|
```json
|
|
{
|
|
"word": {
|
|
"definition": "A single distinct meaningful element of speech or writing",
|
|
"part_of_speech": "noun",
|
|
"example": "The words 'the', 'and', and 'word' are examples of words.",
|
|
"etymology": "Old English word, of Germanic origin; related to Dutch woord and German Wort."
|
|
}
|
|
}
|
|
```
|
|
|
|
Use a free dictionary API (e.g., DictionaryAPI.dev) for initial lookups, then cache in database and localStorage.
|
|
|
|
### 10.2 Dictionary Popup
|
|
|
|
**File:** `web/src/reader/ebook/dictionary-popup.ts`
|
|
|
|
```typescript
|
|
// Dictionary lookup popup for ebooks
|
|
|
|
import { lookupWord } from "./api";
|
|
|
|
function showDictionaryPopup(word: string, position: { x: number; y: number }): void {
|
|
// Remove existing popup
|
|
const existing = document.getElementById('dictionary-popup');
|
|
existing?.remove();
|
|
|
|
// Create popup
|
|
const popup = document.createElement('div');
|
|
popup.id = 'dictionary-popup';
|
|
popup.className = 'absolute bg-white text-black p-4 rounded-lg shadow-xl max-w-md z-50';
|
|
popup.style.left = `${position.x}px`;
|
|
popup.style.top = `${position.y}px`;
|
|
|
|
popup.innerHTML = '<p class="text-sm">Loading...</p>';
|
|
document.body.appendChild(popup);
|
|
|
|
// Look up word
|
|
lookupWord(word).then((entry) => {
|
|
popup.innerHTML = `
|
|
<h3 class="font-bold text-lg">${entry.word}</h3>
|
|
<p class="text-sm italic">${entry.part_of_speech || ''}</p>
|
|
<p class="mt-2">${entry.definition}</p>
|
|
${entry.example ? `<p class="mt-2 text-sm italic">"${entry.example}"</p>` : ''}
|
|
`;
|
|
}).catch((error) => {
|
|
popup.innerHTML = `<p class="text-red-500">Definition not found for "${word}"</p>`;
|
|
});
|
|
|
|
// Close on click outside
|
|
setTimeout(() => {
|
|
document.addEventListener('click', function closePopup(e: MouseEvent) {
|
|
if (!popup.contains(e.target as Node)) {
|
|
popup.remove();
|
|
document.removeEventListener('click', closePopup);
|
|
}
|
|
});
|
|
}, 100);
|
|
}
|
|
|
|
// Text selection handler for ebooks
|
|
function handleTextSelection(): void {
|
|
document.addEventListener('mouseup', () => {
|
|
const selection = window.getSelection();
|
|
const selectedText = selection?.toString().trim();
|
|
|
|
if (selectedText && selectedText.split(' ').length === 1) {
|
|
// Single word selected - show dictionary
|
|
const range = selection?.getRangeAt(0);
|
|
const rect = range?.getBoundingClientRect();
|
|
|
|
if (rect) {
|
|
showDictionaryPopup(selectedText, { x: rect.left, y: rect.bottom });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 11. Reading Statistics Integration
|
|
|
|
### 11.1 Track Reading Speed
|
|
|
|
**File:** `web/src/reader/reading-speed-tracker.ts`
|
|
|
|
```typescript
|
|
// Track reading speed and update database
|
|
|
|
// Reading speed tracker
|
|
// Procedural implementation (no OOP)
|
|
|
|
interface ReadingSpeedTrackerState {
|
|
startTime: number | null;
|
|
pagesRead: number;
|
|
wordsRead: number;
|
|
lastSync: number;
|
|
mediaItemId: string;
|
|
}
|
|
|
|
function createReadingSpeedTracker(mediaItemId: string): ReadingSpeedTrackerState {
|
|
return {
|
|
startTime: null,
|
|
pagesRead: 0,
|
|
wordsRead: 0,
|
|
lastSync: Date.now(),
|
|
mediaItemId
|
|
};
|
|
}
|
|
|
|
function startReadingSession(state: ReadingSpeedTrackerState): ReadingSpeedTrackerState {
|
|
return {
|
|
...state,
|
|
startTime: Date.now(),
|
|
pagesRead: 0,
|
|
wordsRead: 0
|
|
};
|
|
}
|
|
|
|
function recordPageTurn(state: ReadingSpeedTrackerState): ReadingSpeedTrackerState {
|
|
if (!state.startTime) return state;
|
|
|
|
const newPagesRead = state.pagesRead + 1;
|
|
const now = Date.now();
|
|
|
|
if (newPagesRead % 5 === 0 || (now - state.lastSync) > 5 * 60 * 1000) {
|
|
syncReadingSpeed({ ...state, pagesRead: newPagesRead });
|
|
return { ...state, pagesRead: newPagesRead, lastSync: now };
|
|
}
|
|
|
|
return { ...state, pagesRead: newPagesRead };
|
|
}
|
|
|
|
function recordWordsRead(
|
|
state: ReadingSpeedTrackerState,
|
|
wordCount: number
|
|
): ReadingSpeedTrackerState {
|
|
return {
|
|
...state,
|
|
wordsRead: state.wordsRead + wordCount
|
|
};
|
|
}
|
|
|
|
async function syncReadingSpeed(state: ReadingSpeedTrackerState): Promise<void> {
|
|
if (!state.startTime) return;
|
|
|
|
const minutesElapsed = (Date.now() - state.startTime) / (1000 * 60);
|
|
const pagesPerMinute = state.pagesRead / minutesElapsed;
|
|
const wordsPerMinute = state.wordsRead / minutesElapsed;
|
|
|
|
await apiPut(`/readers/${state.mediaItemId}/reading-speed`, {
|
|
pages_per_minute: pagesPerMinute,
|
|
words_per_minute: wordsPerMinute,
|
|
pages_read: state.pagesRead,
|
|
total_reading_minutes: minutesElapsed
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 12. UI/UX Implementation
|
|
|
|
### 12.1 Reader Template (SSR)
|
|
|
|
**File:** `templates/reader.templ` (new file)
|
|
|
|
```go
|
|
package templates
|
|
|
|
templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) {
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>{ metadata.title } - Bookhoard Reader</title>
|
|
<link rel="manifest" href="/static/manifest.json"/>
|
|
<script src="/static/htmx.min.js"></script>
|
|
<link href="/static/style.css" rel="stylesheet"/>
|
|
</head>
|
|
<body
|
|
x-data="readerShell"
|
|
x-init="initReader()"
|
|
class="theme-{ user.Theme }"
|
|
data-media-item-id={ metadata.media_item_id }
|
|
data-media-type={ metadata.library_type }
|
|
data-total-pages={ metadata.total_pages }
|
|
>
|
|
@ReaderChrome(user, metadata, progress)
|
|
|
|
<!-- Dockable Panels Container -->
|
|
<div id="reader-panels" class="fixed inset-0 pointer-events-none z-30">
|
|
<!-- Left Sidebar (TOC, Settings) -->
|
|
<div id="left-sidebar" class="absolute left-0 top-0 bottom-0 pointer-events-auto flex flex-col">
|
|
<div id="toc-panel" class="panel-container pointer-events-auto" data-panel="toc">
|
|
@ReaderTOCPanel(metadata)
|
|
</div>
|
|
<div id="settings-panel" class="panel-container pointer-events-auto" data-panel="settings">
|
|
@ReaderSettingsPanel()
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Sidebar (Navigator, Bookmarks) -->
|
|
<div id="right-sidebar" class="absolute right-0 top-0 bottom-0 pointer-events-auto flex flex-col">
|
|
<div id="navigator-panel" class="panel-container pointer-events-auto" data-panel="navigator">
|
|
@ReaderNavigatorPanel()
|
|
</div>
|
|
<div id="bookmarks-panel" class="panel-container pointer-events-auto" data-panel="bookmarks">
|
|
@ReaderBookmarksPanel(bookmarks)
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<main id="reader-content" class="w-full h-full">
|
|
<!-- Content loaded by JavaScript based on media type -->
|
|
</main>
|
|
|
|
@DictionaryPopup()
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) {
|
|
<div id="reader-chrome" class="transition-opacity duration-300">
|
|
<!-- Top bar -->
|
|
<div class="fixed top-0 left-0 right-0 bg-opacity-95 backdrop-blur border-b z-40" style="background-color: var(--bg-primary);">
|
|
<div class="flex items-center justify-between px-4 py-3">
|
|
<a href="/media-items/{ metadata.media_item_id }" class="text-lg hover:underline">
|
|
← Back
|
|
</a>
|
|
<h1 class="text-lg font-semibold">{ metadata.title }</h1>
|
|
<button
|
|
data-action="open-settings"
|
|
class="p-2 rounded-lg hover:bg-gray-700"
|
|
title="Settings"
|
|
>
|
|
⚙️
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bottom bar -->
|
|
<div class="fixed bottom-0 left-0 right-0 bg-opacity-95 backdrop-blur border-t z-40" style="background-color: var(--bg-primary);">
|
|
<div class="flex items-center justify-between px-4 py-3">
|
|
<div id="progress-display" data-progress-mode="pages">
|
|
{ fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
|
|
</div>
|
|
|
|
<div class="flex items-center gap-4">
|
|
<button data-action="toggle-toc" title="Table of Contents">📖</button>
|
|
<button data-action="add-bookmark" title="Bookmark">🏷️</button>
|
|
<button data-action="add-note" title="Note">📝</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
templ ReaderSettingsPanel() {
|
|
<div
|
|
id="settings-panel"
|
|
class="dockable-panel panel-collapsed"
|
|
data-panel="settings"
|
|
data-side="left"
|
|
>
|
|
<div class="panel-header flex items-center justify-between p-3 cursor-pointer" data-action="toggle-panel">
|
|
<h3 class="panel-title font-semibold">⚙️ Settings</h3>
|
|
<div class="panel-controls flex items-center gap-2">
|
|
<button class="panel-lock" data-action="lock-panel" title="Lock position">🔓</button>
|
|
<button class="window-shade-toggle" data-action="window-shade">─</button>
|
|
</div>
|
|
</div>
|
|
<div class="panel-content p-4 overflow-y-auto">
|
|
<!-- Display settings -->
|
|
<div class="mb-6">
|
|
<h3 class="font-semibold mb-2">Display</h3>
|
|
<label class="block mb-2">
|
|
Chrome Behavior
|
|
<select name="chrome_behavior" class="w-full mt-1 px-3 py-2 rounded border">
|
|
<option value="auto-hide">Auto Hide</option>
|
|
<option value="always-visible">Always Visible</option>
|
|
<option value="hide-on-scroll">Hide on Scroll</option>
|
|
</select>
|
|
</label>
|
|
<label class="block mb-2">
|
|
Progress Mode
|
|
<select name="progress_mode" class="w-full mt-1 px-3 py-2 rounded border">
|
|
<option value="pages">Pages</option>
|
|
<option value="chapter">Chapter</option>
|
|
<option value="percentage">Percentage</option>
|
|
<option value="time-left">Time Left</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Typography (ebooks only) -->
|
|
<div class="mb-6" data-visible-for="ebook">
|
|
<h3 class="font-semibold mb-2">Typography</h3>
|
|
<label class="block mb-2">
|
|
Reading Font
|
|
<select name="reading_font" class="w-full mt-1 px-3 py-2 rounded border">
|
|
<option value="literata">Literata (Default - Designed for ebooks)</option>
|
|
<option value="crimson">Crimson Text (Screen-optimized)</option>
|
|
<option value="source-serif">Source Serif 4 (Adobe quality)</option>
|
|
<option value="eb-garamond">EB Garamond (Classic)</option>
|
|
<option value="libertinus">Libertinus Serif (Technical)</option>
|
|
<option value="noto-serif">Noto Serif (All languages)</option>
|
|
<option value="charis-sil">Charis SIL (Multilingual)</option>
|
|
<option value="ibm-plex">IBM Plex Serif (Modern)</option>
|
|
</select>
|
|
<p class="text-xs mt-1" style="color: var(--text-secondary)">8 libre fonts bundled with Bookhoard</p>
|
|
</label>
|
|
<label class="block mb-2">
|
|
Font Size
|
|
<input type="range" name="font_size" min="12" max="24" value="16" class="w-full"/>
|
|
<span class="text-xs ml-2" style="color: var(--text-secondary)">12-24px</span>
|
|
</label>
|
|
<label class="block mb-2">
|
|
Line Height
|
|
<input type="range" name="line_height" min="1.0" max="2.5" step="0.1" value="1.6" class="w-full"/>
|
|
<span class="text-xs ml-2" style="color: var(--text-secondary)">1.0-2.5</span>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Navigation -->
|
|
<div class="mb-6">
|
|
<h3 class="font-semibold mb-2">Navigation</h3>
|
|
<label class="flex items-center mb-2">
|
|
<input type="checkbox" name="panel_zoom_enabled" class="mr-2"/>
|
|
Panel Zoom (Comics/Manga)
|
|
</label>
|
|
<label class="flex items-center mb-2">
|
|
<input type="checkbox" name="double_page_spread" class="mr-2"/>
|
|
Double Page Spread (Comics/Manga)
|
|
</label>
|
|
</div>
|
|
|
|
<button data-action="close-settings" class="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
|
Done
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
templ ReaderTOCPanel(metadata ReaderMetadata) {
|
|
<div
|
|
id="toc-panel"
|
|
class="dockable-panel"
|
|
data-panel="toc"
|
|
data-side="left"
|
|
>
|
|
<div class="panel-header flex items-center justify-between p-3 cursor-pointer" data-action="toggle-panel">
|
|
<h3 class="panel-title font-semibold">📖 Table of Contents</h3>
|
|
<div class="panel-controls flex items-center gap-2">
|
|
<button class="panel-lock" data-action="lock-panel" title="Lock position">🔓</button>
|
|
<button class="window-shade-toggle" data-action="window-shade">─</button>
|
|
</div>
|
|
</div>
|
|
<div class="panel-content p-4 overflow-y-auto">
|
|
<nav id="toc-list" class="space-y-2">
|
|
<!-- TOC items populated by JavaScript -->
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
templ ReaderNavigatorPanel() {
|
|
<div
|
|
id="navigator-panel"
|
|
class="dockable-panel"
|
|
data-panel="navigator"
|
|
data-side="right"
|
|
>
|
|
<div class="panel-header flex items-center justify-between p-3 cursor-pointer" data-action="toggle-panel">
|
|
<h3 class="panel-title font-semibold">🗺️ Navigator</h3>
|
|
<div class="panel-controls flex items-center gap-2">
|
|
<button class="panel-lock" data-action="lock-panel" title="Lock position">🔓</button>
|
|
<button class="window-shade-toggle" data-action="window-shade">─</button>
|
|
</div>
|
|
</div>
|
|
<div class="panel-content p-2 overflow-hidden">
|
|
<div id="navigator-viewport" class="relative w-full h-full">
|
|
<!-- Full page preview with draggable viewport box -->
|
|
<!-- JavaScript renders current page as scaled thumbnail with draggable viewport -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
templ ReaderBookmarksPanel(bookmarks []Bookmark) {
|
|
<div
|
|
id="bookmarks-panel"
|
|
class="dockable-panel panel-collapsed"
|
|
data-panel="bookmarks"
|
|
data-side="right"
|
|
>
|
|
<div class="panel-header flex items-center justify-between p-3 cursor-pointer" data-action="toggle-panel">
|
|
<h3 class="panel-title font-semibold">🔖 Bookmarks</h3>
|
|
<div class="panel-controls flex items-center gap-2">
|
|
<button class="panel-lock" data-action="lock-panel" title="Lock position">🔓</button>
|
|
<button class="window-shade-toggle" data-action="window-shade">─</button>
|
|
</div>
|
|
</div>
|
|
<div class="panel-content p-4 overflow-y-auto">
|
|
if len(bookmarks) > 0 {
|
|
<div id="bookmarks-list" class="space-y-2">
|
|
for _, bookmark := range bookmarks {
|
|
<a
|
|
href="#"
|
|
data-bookmark-id={ bookmark.ID }
|
|
class="block py-2 hover:bg-gray-700 rounded px-2"
|
|
>
|
|
<span class="font-medium">{ bookmark.Title }</span>
|
|
<span class="text-xs ml-2" style="color: var(--text-secondary)">
|
|
{ bookmark.Position }
|
|
</span>
|
|
</a>
|
|
}
|
|
</div>
|
|
} else {
|
|
<p class="text-sm" style="color: var(--text-secondary)">No bookmarks yet</p>
|
|
}
|
|
<button data-action="add-bookmark" class="w-full py-2 mt-4 bg-blue-600 text-white rounded hover:bg-blue-700">
|
|
+ Add Bookmark
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
templ DictionaryPopup() {
|
|
<div id="dictionary-popup" class="hidden fixed bg-white text-black p-4 rounded-lg shadow-xl max-w-md z-50"></div>
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 13. Integration Tests
|
|
|
|
### 13.1 Test Setup
|
|
|
|
**File:** `cmd/server/tests/reader_test.go` (new file)
|
|
|
|
Follow existing test patterns from `media_test.go` and `auth_test.go`:
|
|
|
|
```go
|
|
package tests
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/handlers"
|
|
"bookhoard/internal/services"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestReaderEndpoints(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
defer setup.Teardown(t)
|
|
|
|
// Create test user and media item
|
|
ctx := setup.Ctx()
|
|
queries := setup.Queries()
|
|
|
|
user := createTestUser(t, ctx, queries)
|
|
admin := createTestAdmin(t, ctx, queries)
|
|
library := createTestLibrary(t, ctx, queries, user.ID)
|
|
mediaItem := createTestMediaItem(t, ctx, queries, library.ID, user.ID)
|
|
|
|
accessToken := loginTestUser(t, setup, user.Email, "password123")
|
|
adminToken := loginTestUser(t, setup, admin.Email, "admin123")
|
|
|
|
t.Run("Get Reader Page - User", func(t *testing.T) {
|
|
// Test SSR reader page
|
|
// Test that user can access their own media items
|
|
req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s", mediaItem.ID), nil)
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
req.Header.Set("Accept", "text/html")
|
|
|
|
resp := httptest.NewRecorder()
|
|
setup.ServeHTTP(resp, req)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.Code)
|
|
assert.Contains(t, resp.Body.String(), mediaItem.Title)
|
|
})
|
|
|
|
t.Run("Get Reader Page - No User", func(t *testing.T) {
|
|
// Test 401 without authentication
|
|
req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s", mediaItem.ID), nil)
|
|
req.Header.Set("Accept", "text/html")
|
|
|
|
resp := httptest.NewRecorder()
|
|
setup.ServeHTTP(resp, req)
|
|
|
|
// Should redirect to login for HTML requests
|
|
assert.Equal(t, http.StatusFound, resp.Code)
|
|
})
|
|
|
|
t.Run("Get Reader Page - API Request", func(t *testing.T) {
|
|
// Test JSON API request returns 401 without auth
|
|
req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s", mediaItem.ID), nil)
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp := httptest.NewRecorder()
|
|
setup.ServeHTTP(resp, req)
|
|
|
|
assert.Equal(t, http.StatusUnauthorized, resp.Code)
|
|
})
|
|
|
|
t.Run("Get Page - Lazy Loading", func(t *testing.T) {
|
|
// Test page lazy loading endpoint
|
|
req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s/pages/1", mediaItem.ID), nil)
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
resp := httptest.NewRecorder()
|
|
setup.ServeHTTP(resp, req)
|
|
|
|
// Should return 200 with page content
|
|
assert.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
var result map[string]interface{}
|
|
err := json.Unmarshal(resp.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, result, "content")
|
|
assert.Contains(t, result, "page_number")
|
|
})
|
|
|
|
t.Run("Get Chapters", func(t *testing.T) {
|
|
// Test chapter metadata endpoint
|
|
req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s/chapters", mediaItem.ID), nil)
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
resp := httptest.NewRecorder()
|
|
setup.ServeHTTP(resp, req)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
var result map[string]interface{}
|
|
err := json.Unmarshal(resp.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, result, "chapters")
|
|
})
|
|
|
|
t.Run("Get Panels - Grid Detection", func(t *testing.T) {
|
|
// Test panel detection endpoint for comics
|
|
// Create a comic media item
|
|
comicItem := createTestComicMediaItem(t, ctx, queries, library.ID, user.ID)
|
|
|
|
req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s/panels/1?method=grid", comicItem.ID), nil)
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
resp := httptest.NewRecorder()
|
|
setup.ServeHTTP(resp, req)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
var result map[string]interface{}
|
|
err := json.Unmarshal(resp.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, result, "panels")
|
|
assert.Contains(t, result, "detection_method")
|
|
})
|
|
|
|
t.Run("Update Panels - Manual Override", func(t *testing.T) {
|
|
// Test manual panel override (all authenticated users)
|
|
comicItem := createTestComicMediaItem(t, ctx, queries, library.ID, user.ID)
|
|
|
|
panels := []map[string]interface{}{
|
|
{
|
|
"id": "panel-1",
|
|
"x": 100,
|
|
"y": 50,
|
|
"width": 400,
|
|
"height": 300,
|
|
"reading_order": 1,
|
|
},
|
|
}
|
|
|
|
requestBody := map[string]interface{}{
|
|
"panels": panels,
|
|
"detection_method": "manual",
|
|
}
|
|
|
|
bodyBytes, err := json.Marshal(requestBody)
|
|
require.NoError(t, err)
|
|
|
|
req := httptest.NewRequest("PUT", fmt.Sprintf("/readers/%s/panels/1", comicItem.ID), bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp := httptest.NewRecorder()
|
|
setup.ServeHTTP(resp, req)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
var result map[string]interface{}
|
|
err = json.Unmarshal(resp.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
assert.True(t, result["success"].(bool))
|
|
})
|
|
|
|
t.Run("Reading Speed - Get", func(t *testing.T) {
|
|
// Test reading speed retrieval
|
|
req := httptest.NewRequest("GET", fmt.Sprintf("/readers/%s/reading-speed", mediaItem.ID), nil)
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
resp := httptest.NewRecorder()
|
|
setup.ServeHTTP(resp, req)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
var result map[string]interface{}
|
|
err := json.Unmarshal(resp.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
// May have zero values if no reading has occurred
|
|
assert.Contains(t, result, "pages_read")
|
|
})
|
|
|
|
t.Run("Reading Speed - Update", func(t *testing.T) {
|
|
// Test reading speed update
|
|
requestBody := map[string]interface{}{
|
|
"pages_read": 10,
|
|
"time_spent_minutes": 15.5,
|
|
}
|
|
|
|
bodyBytes, err := json.Marshal(requestBody)
|
|
require.NoError(t, err)
|
|
|
|
req := httptest.NewRequest("POST", fmt.Sprintf("/readers/%s/reading-speed", mediaItem.ID), bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp := httptest.NewRecorder()
|
|
setup.ServeHTTP(resp, req)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
var result map[string]interface{}
|
|
err = json.Unmarshal(resp.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
assert.True(t, result["success"].(bool))
|
|
})
|
|
|
|
t.Run("Dictionary Lookup", func(t *testing.T) {
|
|
// Test dictionary endpoint
|
|
req := httptest.NewRequest("GET", "/readers/dictionary/example", nil)
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
resp := httptest.NewRecorder()
|
|
setup.ServeHTTP(resp, req)
|
|
|
|
// Should return 200 or 404 depending on whether word is in cache
|
|
assert.True(t, resp.Code == http.StatusOK || resp.Code == http.StatusNotFound)
|
|
|
|
if resp.Code == http.StatusOK {
|
|
var result map[string]interface{}
|
|
err := json.Unmarshal(resp.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, result, "word")
|
|
assert.Contains(t, result, "definition")
|
|
}
|
|
})
|
|
|
|
t.Run("Settings Management - Get", func(t *testing.T) {
|
|
// Test settings retrieval
|
|
req := httptest.NewRequest("GET", "/readers/settings", nil)
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
resp := httptest.NewRecorder()
|
|
setup.ServeHTTP(resp, req)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
var result map[string]interface{}
|
|
err := json.Unmarshal(resp.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
// Should have default settings if never set
|
|
assert.Contains(t, result, "reading_theme")
|
|
assert.Contains(t, result, "font_size")
|
|
})
|
|
|
|
t.Run("Settings Management - Update", func(t *testing.T) {
|
|
// Test settings update
|
|
requestBody := map[string]interface{}{
|
|
"reading_theme": "dark",
|
|
"reading_font": "literata",
|
|
"font_size": 18,
|
|
}
|
|
|
|
bodyBytes, err := json.Marshal(requestBody)
|
|
require.NoError(t, err)
|
|
|
|
req := httptest.NewRequest("PUT", "/readers/settings", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp := httptest.NewRecorder()
|
|
setup.ServeHTTP(resp, req)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
// Verify settings were updated
|
|
var result map[string]interface{}
|
|
err = json.Unmarshal(resp.Body.Bytes(), &result)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "dark", result["reading_theme"])
|
|
assert.Equal(t, "literata", result["reading_font"])
|
|
assert.Equal(t, float64(18), result["font_size"])
|
|
})
|
|
|
|
t.Run("Offline Support - Service Worker", func(t *testing.T) {
|
|
// Test service worker registration
|
|
req := httptest.NewRequest("GET", "/static/sw.js", nil)
|
|
resp := httptest.NewRecorder()
|
|
setup.ServeHTTP(resp, req)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.Code)
|
|
assert.Contains(t, resp.Body.String(), "serviceWorker")
|
|
})
|
|
|
|
t.Run("Offline Support - Manifest", func(t *testing.T) {
|
|
// Test PWA manifest
|
|
req := httptest.NewRequest("GET", "/static/manifest.json", nil)
|
|
resp := httptest.NewRecorder()
|
|
setup.ServeHTTP(resp, req)
|
|
|
|
assert.Equal(t, http.StatusOK, resp.Code)
|
|
|
|
var manifest map[string]interface{}
|
|
err := json.Unmarshal(resp.Body.Bytes(), &manifest)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, manifest, "name")
|
|
assert.Contains(t, manifest, "start_url")
|
|
assert.Contains(t, manifest, "display")
|
|
})
|
|
}
|
|
|
|
// Helper function to create test comic media item
|
|
func createTestComicMediaItem(t *testing.T, ctx context.Context, queries *database.Queries, libraryID uuid.UUID, userID uuid.UUID) database.MediaItems {
|
|
mangaType := pgtype.Text{String: "yes", Valid: true}
|
|
readingDirection := pgtype.Text{String: "rtl", Valid: true}
|
|
|
|
item, err := queries.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
|
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
|
Title: "Test Comic",
|
|
FilePath: "/test/comic.cbz",
|
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
|
MimeType: pgtype.Text{String: "application/vnd.comicbook+zip", Valid: true},
|
|
AddedByAdminID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
MangaType: mangaType,
|
|
ReadingDirection: readingDirection,
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
return item
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 14. Phased Implementation
|
|
|
|
### Phase 1: Infrastructure & Basic Reader (Week 1-2)
|
|
- [ ] Create database schema (panel_data, reading_speed, dictionary_cache, reader_settings)
|
|
- [ ] Create reader service layer (`internal/services/reader_service.go`)
|
|
- [ ] Create reader handlers (`internal/handlers/reader.go`)
|
|
- [ ] Implement all API endpoints (GetPage, GetChapters, GetPanels, etc.)
|
|
- [ ] **Stub ParseEbook to return 501 for server-side formats** (MOBI/AZW3/DOCX/RTF not implemented in Phase 1)
|
|
- [ ] Register reader routes (`internal/router/reader.go`)
|
|
- [ ] API routes: `/readers/:id/pages/:n`, `/readers/:id/chapters`, etc.
|
|
- [ ] SSR route: `/readers/:id` (in `router/frontend.go`)
|
|
- [ ] **Do NOT register parse route** - server-side parsing is Phase 2
|
|
- [ ] Create reader template (`templates/reader.templ`)
|
|
- [ ] Implement reader shell infrastructure
|
|
- [ ] Implement settings manager (DB + localStorage)
|
|
- [ ] Implement progress indicator (KOReader-style)
|
|
- [ ] Create basic ebook reader (HTML rendering) - EPUB/FB2/TXT/HTML only
|
|
- [ ] Create basic comic reader (image display)
|
|
- [ ] Integration tests for infrastructure
|
|
|
|
**Phase 1 Code Cleanup:**
|
|
- [ ] **Remove or comment out stub parser functions** (lines ~579-1001 in handlers/reader.go):
|
|
- [ ] Remove `ParseEbook` method (returns 501, no route registered)
|
|
- [ ] Remove `parseMOBI`, `parseAZW3`, `parseDOCX`, `parseRTF` stubs
|
|
- [ ] Remove helper functions: `extractMOBIMetadata`, `extractMOBIText`, `parseOPFXML`, `openFileFromZip`, `extractDOCXParagraphs`, `extractDOCXMetadata`, `convertDOCXToHTML`, `parseRTFText`, `convertRTFToHTML`, `extractRTFMetadata`
|
|
- [ ] Add TODO comment: "// Phase 2.5: Implement server-side MOBI/AZW3/DOCX/RTF parsers"
|
|
- [ ] Result: handlers/reader.go reduced from ~1000 lines to ~577 lines (working code only)
|
|
- [ ] File compiles without errors
|
|
- [ ] All Phase 1 API endpoints functional
|
|
|
|
**Phase 1 Scope Limitations:**
|
|
- ✅ Client-side parsing: EPUB, FB2, TXT, HTML (TypeScript parsers)
|
|
- ❌ Server-side parsing: MOBI, AZW3, DOCX, RTF (returns "not implemented" error)
|
|
- ✅ PDF reader (using pdf.js)
|
|
- ✅ Comic/Manga readers (image display)
|
|
|
|
### Phase 2: Comic/Manga Features (Week 3-4)
|
|
- [ ] Implement grid-based panel detection
|
|
- [ ] Implement panel zoom with animations
|
|
- [ ] Implement page cache (5-page ahead)
|
|
- [ ] Implement manga RTL navigator
|
|
- [ ] Implement manga vertical scroll mode
|
|
- [ ] Implement chapter detection for all media types
|
|
- [ ] Integration tests for comic/manga features
|
|
|
|
### Phase 2.5: Server-Side Parsers (Week 5)
|
|
**Complete hybrid parsing strategy - add support for complex formats**
|
|
|
|
- [ ] Implement `ParseEbook` endpoint: `POST /readers/:id/parse`
|
|
- [ ] Register route in `router/reader.go`
|
|
- [ ] Route to parser based on MIME type
|
|
- [ ] Implement MOBI parser (`parseMOBI` handler)
|
|
- [ ] Parse MOBI header and metadata
|
|
- [ ] Extract text content (handle Huffman compression)
|
|
- [ ] Convert to CIF (HTML structure)
|
|
- [ ] Unit tests for MOBI parsing
|
|
- [ ] Implement AZW3/KF8 parser (`parseAZW3` handler)
|
|
- [ ] Parse AZW3 container (ZIP-based)
|
|
- [ ] Extract EPUB3 content from KF8 container
|
|
- [ ] Convert to CIF
|
|
- [ ] Unit tests for AZW3 parsing
|
|
- [ ] Implement DOCX parser (`parseDOCX` handler)
|
|
- [ ] Parse DOCX ZIP structure
|
|
- [ ] Extract document.xml for paragraphs
|
|
- [ ] Convert formatting to HTML
|
|
- [ ] Convert to CIF
|
|
- [ ] Unit tests for DOCX parsing
|
|
- [ ] Implement RTF parser (`parseRTF` handler)
|
|
- [ ] Parse RTF markup
|
|
- [ ] Extract text and formatting
|
|
- [ ] Convert to HTML/CIF
|
|
- [ ] Unit tests for RTF parsing
|
|
- [ ] Integration tests for all server-side parsers
|
|
- [ ] Update TypeScript `parser-manager.ts` to call parse endpoint
|
|
- [ ] Update `requiresServerParsing()` to detect MOBI/AZW3/DOCX/RTF
|
|
|
|
**Note:** These formats are complex and require specialized parsing libraries. Consider using:
|
|
- Go's `archive/zip` for ZIP-based formats (AZW3, DOCX)
|
|
- Third-party libraries for MOBI decompression
|
|
- Custom RTF parser (spec is public)
|
|
|
|
### Phase 3: Advanced Features (Week 6-7)
|
|
- [ ] Implement ML-based panel detection (optional enhancement)
|
|
- [ ] Implement manual panel editor
|
|
- [ ] Implement dictionary popup for ebooks
|
|
- [ ] Implement offline dictionary cache
|
|
- [ ] Implement reading speed tracker
|
|
- [ ] Implement annotation manager (highlights, notes, bookmarks)
|
|
- [ ] Integration tests for advanced features
|
|
|
|
### Phase 4: Offline Support (Week 7)
|
|
- [ ] Create service worker
|
|
- [ ] Implement page caching for offline reading
|
|
- [ ] Implement dictionary offline caching
|
|
- [ ] Create PWA manifest
|
|
- [ ] Implement online/offline detection
|
|
- [ ] Integration tests for offline support
|
|
|
|
### Phase 5: Polish & Testing (Week 8)
|
|
- [ ] Performance optimization
|
|
- [ ] Cross-browser testing
|
|
- [ ] Mobile responsiveness testing
|
|
- [ ] Accessibility testing
|
|
- [ ] Security audit
|
|
- [ ] Documentation (user guides, API docs)
|
|
- [ ] End-to-end testing
|
|
|
|
---
|
|
|
|
## 15. Code Reuse Strategy
|
|
|
|
### 15.1 Reuse Existing Systems
|
|
|
|
**WebSocket Sync (`internal/sync/websocket.go`)**
|
|
- Reuse for real-time progress updates
|
|
- Reuse for annotation sync
|
|
- Reuse for bookmark sync
|
|
|
|
**Progress Tracking (`internal/sync/progress.go`)**
|
|
- Reuse EPUB CFI navigation logic
|
|
- Reuse percentage calculation
|
|
- Reuse chapter-relative page calculation
|
|
|
|
**Format Handling (`internal/sync/format.go`)**
|
|
- Reuse format detection logic
|
|
- Reuse normalization functions
|
|
|
|
**Annotation Tables (notes, highlights)**
|
|
- Reuse existing database schema
|
|
- Reuse existing API endpoints
|
|
- Build UI on top of existing data
|
|
|
|
**Theme System (11 dark themes)**
|
|
- Reuse existing theme CSS variables
|
|
- Apply theme to reader UI
|
|
- Ensure consistency across app
|
|
|
|
**Auth & User Management**
|
|
- Reuse JWT middleware
|
|
- Reuse user preferences
|
|
- Reuse role-based access control
|
|
|
|
### 15.2 Surgical Code Edits
|
|
|
|
**Avoid:**
|
|
- ❌ Duplicating existing logic
|
|
- ❌ Rewriting working code
|
|
- ❌ Creating parallel systems
|
|
|
|
**Do:**
|
|
- ✅ Extend existing types where appropriate
|
|
- ✅ Add new methods to existing services
|
|
- ✅ Follow existing patterns and conventions
|
|
- ✅ Use existing test helpers
|
|
|
|
**Example - Extending sync/format.go:**
|
|
|
|
```go
|
|
// EXISTING CODE in sync/format.go
|
|
func CalculateProgress(currentPage, totalPages int) float64 {
|
|
if totalPages == 0 {
|
|
return 0
|
|
}
|
|
return float64(currentPage) / float64(totalPages) * 100
|
|
}
|
|
|
|
// NEW CODE - Add chapter-relative progress
|
|
func CalculateChapterProgress(currentPage, chapterStartPage, chapterPages int) (int, int) {
|
|
chapterPage := currentPage - chapterStartPage + 1
|
|
return chapterPage, chapterPages
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 16. Bruno API Tests
|
|
|
|
**File:** `bruno/reader/get-reader-page.bru`
|
|
|
|
```yaml
|
|
meta:
|
|
name: Get Reader Page
|
|
type: http
|
|
seq: 1
|
|
http:
|
|
method: GET
|
|
url: '{{base_url}}/readers/{{media_item_id}}'
|
|
auth: inherit
|
|
body:
|
|
type: none
|
|
docs: |-
|
|
## Get Reader Page
|
|
|
|
Returns the reader page for a specific media item.
|
|
|
|
**Authentication:** Required (Bearer token)
|
|
|
|
**Path Parameters:**
|
|
- `media_item_id` (string): Media Item UUID
|
|
|
|
**Response:** HTML reader page
|
|
|
|
**Error Responses:**
|
|
- 401: Invalid authentication
|
|
- 404: Media item not found
|
|
- 403: User does not have access to this library
|
|
```
|
|
|
|
**File:** `bruno/reader/get-page.bru`
|
|
|
|
```yaml
|
|
meta:
|
|
name: Get Book Page
|
|
type: http
|
|
seq: 2
|
|
http:
|
|
method: GET
|
|
url: '{{base_url}}/readers/{{media_item_id}}/pages/{{page_number}}'
|
|
auth: inherit
|
|
body:
|
|
type: none
|
|
docs: |-
|
|
## Get Book Page
|
|
|
|
Retrieves a specific page of the book for rendering.
|
|
|
|
**Authentication:** Required (Bearer token)
|
|
|
|
**Path Parameters:**
|
|
- `media_item_id` (string): Media Item UUID
|
|
- `page_number` (number): Page number to retrieve
|
|
|
|
**Query Parameters:**
|
|
- `format` (string, optional): Response format ('html', 'text', 'json')
|
|
|
|
**Response:**
|
|
- `content` (string): Page content (HTML or text)
|
|
- `page_number` (number): Current page number
|
|
- `total_pages` (number): Total pages in book
|
|
- `chapter_title` (string, optional): Current chapter title
|
|
|
|
**Error Responses:**
|
|
- 401: Invalid authentication
|
|
- 404: Page not found
|
|
```
|
|
|
|
**File:** `bruno/reader/get-chapters.bru`
|
|
|
|
```yaml
|
|
meta:
|
|
name: Get Chapters
|
|
type: http
|
|
seq: 3
|
|
http:
|
|
method: GET
|
|
url: '{{base_url}}/readers/{{media_item_id}}/chapters'
|
|
auth: inherit
|
|
body:
|
|
type: none
|
|
docs: |-
|
|
## Get Chapters
|
|
|
|
Retrieves the table of contents/chapter list for a book.
|
|
|
|
**Authentication:** Required (Bearer token)
|
|
|
|
**Path Parameters:**
|
|
- `media_item_id` (string): Media Item UUID
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"chapters": [
|
|
{
|
|
"id": "chapter-1",
|
|
"title": "Chapter 1",
|
|
"start_page": 1,
|
|
"page_count": 25,
|
|
"level": 1
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
**Error Responses:**
|
|
- 401: Invalid authentication
|
|
- 404: Media item not found
|
|
```
|
|
|
|
**File:** `bruno/reader/get-panels.bru`
|
|
|
|
```yaml
|
|
meta:
|
|
name: Get Comic Panels
|
|
type: http
|
|
seq: 4
|
|
http:
|
|
method: GET
|
|
url: '{{base_url}}/readers/{{media_item_id}}/panels/{{page_number}}'
|
|
auth: inherit
|
|
body:
|
|
type: none
|
|
docs: |-
|
|
## Get Comic Panels
|
|
|
|
Retrieves panel detection data for a comic/manga page.
|
|
|
|
**Authentication:** Required (Bearer token)
|
|
|
|
**Path Parameters:**
|
|
- `media_item_id` (string): Media Item UUID
|
|
- `page_number` (number): Page number
|
|
|
|
**Query Parameters:**
|
|
- `method` (string, optional): Detection method ('grid', 'ml', 'manual')
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"page_number": 1,
|
|
"detection_method": "ml",
|
|
"panels": [
|
|
{
|
|
"id": "panel-1",
|
|
"x": 100,
|
|
"y": 50,
|
|
"width": 400,
|
|
"height": 300,
|
|
"reading_order": 1
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
**Error Responses:**
|
|
- 401: Invalid authentication
|
|
- 404: Page not found
|
|
```
|
|
|
|
**File:** `bruno/reader/update-panels.bru`
|
|
|
|
```yaml
|
|
meta:
|
|
name: Update Comic Panels
|
|
type: http
|
|
seq: 5
|
|
http:
|
|
method: PUT
|
|
url: '{{base_url}}/readers/{{media_item_id}}/panels/{{page_number}}'
|
|
auth: inherit
|
|
body:
|
|
type: json
|
|
json: {
|
|
"panels": [
|
|
{
|
|
"id": "panel-1",
|
|
"x": 100,
|
|
"y": 50,
|
|
"width": 400,
|
|
"height": 300,
|
|
"reading_order": 1
|
|
}
|
|
],
|
|
"detection_method": "manual"
|
|
}
|
|
docs: |-
|
|
## Update Comic Panels
|
|
|
|
Updates panel detection data (manual override).
|
|
|
|
**Authentication:** Required (Bearer token)
|
|
|
|
**Path Parameters:**
|
|
- `media_item_id` (string): Media Item UUID
|
|
- `page_number` (number): Page number
|
|
|
|
**Request Body:**
|
|
```json
|
|
{
|
|
"panels": [...],
|
|
"detection_method": "manual"
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"success": true,
|
|
"message": "Panels updated successfully"
|
|
}
|
|
```
|
|
|
|
**Error Responses:**
|
|
- 401: Invalid authentication
|
|
- 400: Invalid panel data
|
|
```
|
|
|
|
**File:** `bruno/reader/get-reading-speed.bru`
|
|
|
|
```yaml
|
|
meta:
|
|
name: Get Reading Speed
|
|
type: http
|
|
seq: 6
|
|
http:
|
|
method: GET
|
|
url: '{{base_url}}/readers/{{media_item_id}}/reading-speed'
|
|
auth: inherit
|
|
body:
|
|
type: none
|
|
docs: |-
|
|
## Get Reading Speed
|
|
|
|
Retrieves reading speed statistics for a book.
|
|
|
|
**Authentication:** Required (Bearer token)
|
|
|
|
**Path Parameters:**
|
|
- `media_item_id` (string): Media Item UUID
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"words_per_minute": 250,
|
|
"pages_per_minute": 1.5,
|
|
"pages_read": 50,
|
|
"total_reading_minutes": 33.3,
|
|
"last_read_at": "2024-01-15T10:30:00Z"
|
|
}
|
|
```
|
|
|
|
**Error Responses:**
|
|
- 401: Invalid authentication
|
|
- 404: Media item not found
|
|
```
|
|
|
|
**File:** `bruno/reader/update-reading-speed.bru`
|
|
|
|
```yaml
|
|
meta:
|
|
name: Update Reading Speed
|
|
type: http
|
|
seq: 7
|
|
http:
|
|
method: POST
|
|
url: '{{base_url}}/readers/{{media_item_id}}/reading-speed'
|
|
auth: inherit
|
|
body:
|
|
type: json
|
|
json: {
|
|
"pages_read": 10,
|
|
"time_spent_minutes": 15.5
|
|
}
|
|
docs: |-
|
|
## Update Reading Speed
|
|
|
|
Updates reading speed statistics.
|
|
|
|
**Authentication:** Required (Bearer token)
|
|
|
|
**Path Parameters:**
|
|
- `media_item_id` (string): Media Item UUID
|
|
|
|
**Request Body:**
|
|
```json
|
|
{
|
|
"pages_read": 10,
|
|
"time_spent_minutes": 15.5
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"success": true,
|
|
"words_per_minute": 250,
|
|
"pages_per_minute": 1.5
|
|
}
|
|
```
|
|
|
|
**Error Responses:**
|
|
- 401: Invalid authentication
|
|
- 400: Invalid data
|
|
```
|
|
|
|
**File:** `bruno/reader/lookup-word.bru`
|
|
|
|
```yaml
|
|
meta:
|
|
name: Dictionary Lookup
|
|
type: http
|
|
seq: 8
|
|
http:
|
|
method: GET
|
|
url: '{{base_url}}/readers/dictionary/{{word}}'
|
|
auth: inherit
|
|
body:
|
|
type: none
|
|
docs: |-
|
|
## Dictionary Lookup
|
|
|
|
Looks up a word in the dictionary (offline-capable).
|
|
|
|
**Authentication:** Required (Bearer token)
|
|
|
|
**Path Parameters:**
|
|
- `word` (string): Word to look up
|
|
|
|
**Query Parameters:**
|
|
- `lang` (string, optional): Language code (default: 'en')
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"word": "example",
|
|
"definition": "A representative form or pattern.",
|
|
"part_of_speech": "noun",
|
|
"example": "This is an example sentence.",
|
|
"etymology": "From Latin exemplum"
|
|
}
|
|
```
|
|
|
|
**Error Responses:**
|
|
- 401: Invalid authentication
|
|
- 404: Word not found
|
|
```
|
|
|
|
**File:** `bruno/reader/get-settings.bru`
|
|
|
|
```yaml
|
|
meta:
|
|
name: Get Reader Settings
|
|
type: http
|
|
seq: 9
|
|
http:
|
|
method: GET
|
|
url: '{{base_url}}/readers/settings'
|
|
auth: inherit
|
|
body:
|
|
type: none
|
|
docs: |-
|
|
## Get Reader Settings
|
|
|
|
Retrieves user's reader settings.
|
|
|
|
**Authentication:** Required (Bearer token)
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"chrome_behavior": "auto-hide",
|
|
"progress_mode": "pages",
|
|
"chrome_theme": "tokyo-night",
|
|
"reading_theme": "dark",
|
|
"reading_font": "literata",
|
|
"font_size": 16,
|
|
"line_height": 1.6,
|
|
"margin_width": 20
|
|
}
|
|
```
|
|
|
|
**Error Responses:**
|
|
- 401: Invalid authentication
|
|
```
|
|
|
|
**File:** `bruno/reader/update-settings.bru`
|
|
|
|
```yaml
|
|
meta:
|
|
name: Update Reader Settings
|
|
type: http
|
|
seq: 10
|
|
http:
|
|
method: PUT
|
|
url: '{{base_url}}/readers/settings'
|
|
auth: inherit
|
|
body:
|
|
type: json
|
|
json: {
|
|
"reading_theme": "dark",
|
|
"reading_font": "literata",
|
|
"font_size": 18
|
|
}
|
|
docs: |-
|
|
## Update Reader Settings
|
|
|
|
Updates user's reader settings (partial update supported).
|
|
|
|
**Authentication:** Required (Bearer token)
|
|
|
|
**Request Body:** Partial settings object (only include fields to update)
|
|
```json
|
|
{
|
|
"reading_theme": "dark",
|
|
"reading_font": "literata",
|
|
"font_size": 18
|
|
}
|
|
```
|
|
|
|
**Response:** Updated settings object (same format as GET)
|
|
|
|
**Error Responses:**
|
|
- 401: Invalid authentication
|
|
- 400: Invalid setting value
|
|
```
|
|
|
|
---
|
|
|
|
## 17. Documentation
|
|
|
|
### 17.1 User Documentation
|
|
|
|
**File:** `docs/user/reader.md` (new file)
|
|
|
|
Comprehensive user guide covering:
|
|
- How to open the reader
|
|
- Navigation controls
|
|
- Progress indicator modes
|
|
- Settings options
|
|
- Panel zoom for comics/manga
|
|
- Dictionary lookup
|
|
- Bookmarks, highlights, notes
|
|
- Offline reading
|
|
- Keyboard shortcuts
|
|
|
|
### 17.2 Developer Documentation
|
|
|
|
**File:** `docs/contributing/reader-architecture.md` (new file)
|
|
|
|
Technical documentation covering:
|
|
- Reader architecture overview
|
|
- Component structure
|
|
- Data flow diagrams
|
|
- Panel detection algorithms
|
|
- Caching strategy
|
|
- Offline support implementation
|
|
- Testing strategy
|
|
|
|
---
|
|
|
|
## 18. Success Criteria
|
|
|
|
### 18.1 Functional Requirements
|
|
- ✅ User can read ebooks (EPUB) with adjustable typography
|
|
- ✅ User can read comics (CBZ/CBR/PDF) with panel zoom
|
|
- ✅ User can read manga with RTL and vertical scroll modes
|
|
- ✅ Progress syncs across devices via WebSocket
|
|
- ✅ User can create bookmarks, highlights, notes
|
|
- ✅ User can look up words in dictionary (offline)
|
|
- ✅ Reader works offline for cached content
|
|
- ✅ Settings persist across devices (DB) and browsers (localStorage)
|
|
- ✅ 8 bundled libre reading fonts (no network requests)
|
|
- ✅ UI chrome uses all 11 Bookhoard themes, ebook text uses 5 reading-optimized themes
|
|
|
|
### 18.2 Performance Requirements
|
|
- ⚡ Initial page load: < 2 seconds
|
|
- ⚡ Page turn (comics): < 500ms with 5-page cache
|
|
- ⚡ Panel zoom animation: 300ms smooth
|
|
- ⚡ Dictionary lookup: < 1 second (cached), < 3 seconds (uncached)
|
|
- ⚡ Offline cache hit: < 100ms
|
|
|
|
### 18.3 Quality Requirements
|
|
- ✅ Zero TypeScript errors
|
|
- ✅ All integration tests passing
|
|
- ✅ Zero known security vulnerabilities
|
|
- ✅ Mobile-responsive (320px - 4K)
|
|
- ✅ Keyboard accessible
|
|
- ✅ WCAG 2.1 AA compliant
|
|
|
|
---
|
|
|
|
## 19. Future Enhancements (Out of Scope for Initial Implementation)
|
|
|
|
- TTS (Text-to-Speech) - user excluded
|
|
- Advanced ML panel detection with custom model
|
|
- Social features (share highlights, see friends' progress)
|
|
- Advanced annotations (draw on pages, voice notes)
|
|
- PDF form filling
|
|
- EPUB audio/video media overlays
|
|
- Advanced manga panel navigation (auto-detect panel order)
|
|
- Reading goals and challenges
|
|
- Social reading (book clubs, shared annotations)
|
|
|
|
---
|
|
|
|
## Conclusion
|
|
|
|
This implementation plan provides a comprehensive roadmap for building a modern, feature-rich web reader for Bookhoard. The **universal reader architecture with pluggable parsers** provides the best balance of code reuse, maintainability, and extensibility.
|
|
|
|
**Key principles:**
|
|
- **Universal reader**: One rendering engine for all reflowable ebooks (EPUB, FB2, TXT, HTML, MOBI, AZW3, DOCX, RTF)
|
|
- **Common Intermediate Format (CIF)**: Standardized HTML structure that all parsers produce
|
|
- **Hybrid parsing**: Client-side for simple formats (~500 KB), server-side for complex formats (no 182 MB Calibre dependency)
|
|
- **Procedural TypeScript**: Functions, not classes (per PROJECT_GUIDELINES.md)
|
|
- **Surgical code reuse**: Extend existing systems (WebSocket sync, progress tracking, annotations)
|
|
- **Progressive enhancement**: SSR-first with TypeScript enhancements
|
|
- **Privacy-first**: Per-user settings with localStorage fallback
|
|
- **Offline-capable**: PWA with service worker
|
|
- **Libre fonts only**: 8 bundled open-source reading fonts
|
|
- **Hybrid theming**: 11 themes for UI/comics, 5 reading-optimized themes for ebook text
|
|
|
|
**Supported Formats:**
|
|
|
|
| Format | Parser Location | Dependency Size | Status |
|
|
|--------|-----------------|-----------------|--------|
|
|
| **EPUB 2/3** | Client (TypeScript) | 0 KB (JSZip) | ✅ Planned |
|
|
| **FB2** | Client (TypeScript) | 0 KB (XML) | ✅ Planned |
|
|
| **TXT** | Client (TypeScript) | 0 KB | ✅ Planned |
|
|
| **HTML** | Client (TypeScript) | 0 KB | ✅ Planned |
|
|
| **MOBI** | Server (Go) | ~100 KB | ✅ Planned |
|
|
| **AZW3** | Server (Go) | ~50 KB | ✅ Planned |
|
|
| **DOCX** | Server (Go) | ~200 KB (mammoth) | ✅ Planned |
|
|
| **RTF** | Server (Go) | ~50 KB | ✅ Planned |
|
|
| **PDF** | Client (pdf.js) | ~500 KB | ✅ Planned |
|
|
| **Comics** | Client (canvas) | 0 KB | ✅ Planned |
|
|
| **Manga** | Client (extends comics) | 0 KB | ✅ Planned |
|
|
|
|
**Total client-side dependencies: ~1 MB (vs. 182 MB for Calibre)**
|
|
|
|
**Key design decisions:**
|
|
- **Architecture**: Universal reader + parser pipeline (not separate readers)
|
|
- **Parsing**: Hybrid (client for simple, server for complex)
|
|
- **Code style**: Procedural TypeScript (no OOP per guidelines)
|
|
- **Fonts**: 8 libre fonts bundled (~1.2MB WOFF2), standard weights only
|
|
- **Theming**: Hybrid - 11 themes for UI, 5 reading-optimized themes for text
|
|
- **Typography**: Optimized for extended reading (Literata default)
|
|
|
|
**Estimated timeline:** 8 weeks for full implementation
|
|
|
|
**Next steps:**
|
|
1. Review and approve this plan
|
|
2. Begin Phase 1: Infrastructure & Basic Reader
|
|
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)
|
|
7. Add server-side parsers for complex formats (MOBI, AZW3, DOCX)
|
|
|
|
---
|
|
|
|
*Plan created: 2025*
|
|
*Last updated: 2025*
|
|
*Major revision: Universal reader architecture + procedural TypeScript*
|