docs: update reader implementation plan with dockable panels
- Add modular dockable panel architecture with: - Panel dock system (drag, lock, snap-back, window-shade) - TOC, Settings, Navigator, Bookmarks as dockable components - Lock toggle to prevent accidental moves - Snap-back to last valid position if dropped in invalid area - Per-user layout stored in reader_settings JSONB - Update TypeScript types with PanelLayoutSettings and PanelState - Add panel-dock-system.ts implementation to plan - Add navigator-panel.ts for Affinity-style page navigation - Update reader template with dockable panels and lock buttons - Add Section 2.4 with database/Go implementation issues and fixes - Document schema changes (DECIMAL -> REAL) - Document all type fixes needed in reader.go
This commit is contained in:
+657
-27
@@ -16,7 +16,8 @@ Build a modern, responsive web reader for ebooks, comics, manga, and PDFs with f
|
||||
- **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, mini-map, copy support
|
||||
- **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
|
||||
|
||||
@@ -543,6 +544,18 @@ PDF and Comics use dedicated readers (not CIF pipeline):
|
||||
```
|
||||
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
|
||||
@@ -550,6 +563,12 @@ Reader Infrastructure (Shared)
|
||||
├── 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)
|
||||
@@ -585,15 +604,14 @@ PDF Reader (Fixed Layout)
|
||||
├── pdf-clipboard.ts - Copy to clipboard
|
||||
├── pdf-links.ts - Internal link handling
|
||||
├── pdf-dual-page.ts - Dual page spread view
|
||||
├── pdf-minimap.ts - Mini-map navigation
|
||||
├── pdf-rotation.ts - Rotated page support
|
||||
└── pdf-page-sizes.ts - Variable page size handling
|
||||
|
||||
Comic Reader (Image Archives)
|
||||
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
|
||||
├── panel-navigator.ts - Panel zoom with smooth animations (when detection ON)
|
||||
└── page-cache.ts - 5-page ahead cache
|
||||
|
||||
Manga Reader (extends Comic)
|
||||
@@ -799,6 +817,117 @@ Add the above tables to the schema file. Follow existing patterns:
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -2105,6 +2234,30 @@ interface ReaderSettings {
|
||||
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 {
|
||||
@@ -3156,11 +3309,252 @@ function getDefaultSettings(): ReaderSettings {
|
||||
margin_width: 20,
|
||||
double_page_spread: false,
|
||||
reading_direction: 'ltr',
|
||||
hardware_acceleration: true
|
||||
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
|
||||
@@ -8238,7 +8632,156 @@ function getMiniMapStyles(): string {
|
||||
}
|
||||
```
|
||||
|
||||
### 6.14 PDF Rotated Page Support
|
||||
### 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`
|
||||
|
||||
@@ -9943,13 +10486,33 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
|
||||
>
|
||||
@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>
|
||||
|
||||
@ReaderSettingsPanel()
|
||||
@ReaderTOCPanel(metadata)
|
||||
|
||||
@DictionaryPopup()
|
||||
</body>
|
||||
</html>
|
||||
@@ -9992,10 +10555,20 @@ templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
|
||||
}
|
||||
|
||||
templ ReaderSettingsPanel() {
|
||||
<div id="settings-panel" class="fixed inset-y-0 left-0 w-80 bg-opacity-95 backdrop-blur border-r transform -translate-x-full transition-transform duration-300 z-50" style="background-color: var(--bg-secondary);">
|
||||
<div class="p-4">
|
||||
<h2 class="text-xl font-bold mb-4">Settings</h2>
|
||||
|
||||
<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>
|
||||
@@ -10068,28 +10641,85 @@ templ ReaderSettingsPanel() {
|
||||
}
|
||||
|
||||
templ ReaderTOCPanel(metadata ReaderMetadata) {
|
||||
<div id="toc-panel" class="fixed inset-y-0 left-0 w-80 bg-opacity-95 backdrop-blur border-r transform -translate-x-full transition-transform duration-300 z-50" style="background-color: var(--bg-secondary);">
|
||||
<div class="p-4">
|
||||
<h2 class="text-xl font-bold mb-4">Table of Contents</h2>
|
||||
<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>
|
||||
}
|
||||
|
||||
<div id="toc-content">
|
||||
if metadata.chapter_metadata && len(metadata.chapter_metadata.Chapters) > 0 {
|
||||
for _, chapter := range metadata.chapter_metadata.Chapters {
|
||||
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-chapter-id={ chapter.ID }
|
||||
data-bookmark-id={ bookmark.ID }
|
||||
class="block py-2 hover:bg-gray-700 rounded px-2"
|
||||
>
|
||||
{ chapter.Title }
|
||||
<span class="font-medium">{ bookmark.Title }</span>
|
||||
<span class="text-xs ml-2" style="color: var(--text-secondary)">
|
||||
{ bookmark.Position }
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
} else {
|
||||
<p class="text-sm">No chapters available</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button data-action="close-toc" class="w-full py-2 mt-4 bg-gray-700 text-white rounded hover:bg-gray-600">
|
||||
Close
|
||||
} 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>
|
||||
|
||||
@@ -64,6 +64,7 @@ type Config struct {
|
||||
ScannerHandler *handlers.Handler
|
||||
JobsHandler *handlers.JobsHandler
|
||||
SidecarHandler *handlers.SidecarHandler
|
||||
ReaderHandler *handlers.ReaderHandler
|
||||
}
|
||||
|
||||
// createJWTMiddleware creates a JWT middleware with proper user context setup
|
||||
@@ -218,6 +219,7 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
||||
registerJobRoutes(cfg)
|
||||
registerFiltersRoutes(cfg)
|
||||
registerOPDSRoutes(cfg)
|
||||
registerReaderRoutes(cfg)
|
||||
registerWebSocketRoutes(cfg)
|
||||
registerFrontendRoutes(cfg)
|
||||
registerDocumentationRoutes(cfg)
|
||||
|
||||
Reference in New Issue
Block a user