Removed 3 duplicate/incomplete sections: - Section 5.8 (incomplete typography-engine.ts without font support) - Section 5.12 (duplicate search.ts - kept version in section 5.9) - Section 5.10.4 (incomplete reader.templ template - kept complete version in section 12.1) The plan now contains only complete, implementation-ready sections: - Complete typography-engine.ts with getFontStack() and all 8 bundled fonts - Complete search.ts matching actual implementation with export functions - Complete reader.templ SSR template with Alpine.js integration This resolves confusion about which version to follow and ensures the plan matches the actual codebase implementation.
10871 lines
316 KiB
Markdown
10871 lines
316 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. Lazy Loading & Caching
|
|
|
|
### 8.1 Page Cache (5-Page Ahead)
|
|
|
|
**File:** `web/src/reader/comic/page-cache.ts`
|
|
|
|
```typescript
|
|
// Lazy-loading page cache with 5-page ahead prefetch
|
|
|
|
// Lazy-loading page cache with 5-page ahead prefetch
|
|
// Procedural implementation (no OOP)
|
|
|
|
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 { ...newState, 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*
|