Add complete manga reader implementation section to plan
Added Section 8: Manga Reader Implementation with 5 subsections: 8.1 RTL Navigator (Right-to-Left Reading) - Reverse page turn direction for traditional manga - Proper key bindings for RTL navigation - Progress tracking adapted for RTL 8.2 Vertical Scroll Mode (Webtoon Style) - Infinite vertical scroll for webtoons/manhwa - Lazy loading with threshold-based prefetching - Scroll position to page number mapping - Memory-efficient blob URL cleanup 8.3 Reading Direction Detection - Auto-detection from metadata (manga_type, reading_direction) - Filename-based heuristics (manga, manhwa, webtoon) - User preference support with fallback - Integration with database schema 8.4 Manga Settings Integration - Reading direction preference (auto/ltr/rtl/vertical) - Vertical scroll speed control - RTL page transition effects - Settings persistence via API 8.5 Manga Page Cache (shared with comics) - 5-page ahead prefetching - Memory management with cleanup - Shared caching strategy for comics and manga This fills the gap where manga was architecturally planned but had no implementation details. Database schema, types, and UI already supported manga - now the implementation is documented. Plan now has complete coverage for all 4 media types: ebook, comic, manga, pdf
This commit is contained in:
@@ -9079,17 +9079,412 @@ const chapterMarkerCSS = `
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Lazy Loading & Caching
|
## 8. Manga Reader Implementation
|
||||||
|
|
||||||
### 8.1 Page Cache (5-Page Ahead)
|
Manga extends the comic reader with specialized reading modes:
|
||||||
|
- **RTL (Right-to-Left)** - Traditional Japanese manga reading
|
||||||
|
- **Vertical Scroll** - Webtoon/manhwa style (infinite vertical scroll)
|
||||||
|
|
||||||
**File:** `web/src/reader/comic/page-cache.ts`
|
### 8.1 RTL Navigator (Right-to-Left Reading)
|
||||||
|
|
||||||
|
**File:** `web/src/reader/manga/rtl-navigator.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Right-to-left navigation for manga
|
||||||
|
// Reverses page turn direction and key bindings
|
||||||
|
|
||||||
|
interface RTLNavigatorState {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
readingDirection: 'rtl' | 'ltr';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRTLNavigator(totalPages: number): RTLNavigatorState {
|
||||||
|
return {
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages,
|
||||||
|
readingDirection: 'rtl'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextPage(state: RTLNavigatorState): number {
|
||||||
|
// In RTL, "next" page means moving left (decreasing page number)
|
||||||
|
if (state.readingDirection === 'rtl') {
|
||||||
|
return Math.max(1, state.currentPage - 1);
|
||||||
|
}
|
||||||
|
return Math.min(state.totalPages, state.currentPage + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviousPage(state: RTLNavigatorState): number {
|
||||||
|
// In RTL, "previous" page means moving right (increasing page number)
|
||||||
|
if (state.readingDirection === 'rtl') {
|
||||||
|
return Math.min(state.totalPages, state.currentPage + 1);
|
||||||
|
}
|
||||||
|
return Math.max(1, state.currentPage - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToPage(state: RTLNavigatorState, pageNumber: number): RTLNavigatorState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentPage: Math.max(1, Math.min(state.totalPages, pageNumber))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProgress(state: RTLNavigatorState): { current: number; total: number } {
|
||||||
|
return {
|
||||||
|
current: state.currentPage,
|
||||||
|
total: state.totalPages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReadingProgressPercentage(state: RTLNavigatorState): number {
|
||||||
|
return (state.currentPage / state.totalPages) * 100;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Vertical Scroll Mode (Webtoon Style)
|
||||||
|
|
||||||
|
**File:** `web/src/reader/manga/vertical-scroll-mode.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Vertical scroll mode for webtoons/manhwa
|
||||||
|
// Infinite scroll with image loading and lazy rendering
|
||||||
|
|
||||||
|
interface VerticalScrollState {
|
||||||
|
container: HTMLElement;
|
||||||
|
loadedPages: Set<number>;
|
||||||
|
loadingPages: Set<number>;
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
threshold: number; // Distance from bottom to trigger next page load
|
||||||
|
mediaItemId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createVerticalScroll(
|
||||||
|
container: HTMLElement,
|
||||||
|
mediaItemId: string,
|
||||||
|
totalPages: number
|
||||||
|
): VerticalScrollState {
|
||||||
|
const state: VerticalScrollState = {
|
||||||
|
container,
|
||||||
|
loadedPages: new Set(),
|
||||||
|
loadingPages: new Set(),
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages,
|
||||||
|
threshold: 500, // Load next page when 500px from bottom
|
||||||
|
mediaItemId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial page load
|
||||||
|
loadPage(state, 1);
|
||||||
|
|
||||||
|
// Setup scroll listener
|
||||||
|
setupScrollListener(state);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPage(
|
||||||
|
state: VerticalScrollState,
|
||||||
|
pageNumber: number
|
||||||
|
): Promise<void> {
|
||||||
|
if (state.loadedPages.has(pageNumber) || state.loadingPages.has(pageNumber)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.loadingPages.add(pageNumber);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const response = await fetch(
|
||||||
|
`/readers/${state.mediaItemId}/pages/${pageNumber}`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load page ${pageNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const imgUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const pageContainer = document.createElement('div');
|
||||||
|
pageContainer.className = 'vertical-page';
|
||||||
|
pageContainer.dataset.pageNumber = pageNumber.toString();
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = imgUrl;
|
||||||
|
img.alt = `Page ${pageNumber}`;
|
||||||
|
img.loading = 'lazy';
|
||||||
|
|
||||||
|
pageContainer.appendChild(img);
|
||||||
|
state.container.appendChild(pageContainer);
|
||||||
|
|
||||||
|
state.loadedPages.add(pageNumber);
|
||||||
|
state.loadingPages.delete(pageNumber);
|
||||||
|
|
||||||
|
// Load next pages proactively
|
||||||
|
if (pageNumber < state.totalPages) {
|
||||||
|
loadPage(state, pageNumber + 1);
|
||||||
|
if (pageNumber + 1 < state.totalPages) {
|
||||||
|
loadPage(state, pageNumber + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load page ${pageNumber}:`, error);
|
||||||
|
state.loadingPages.delete(pageNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupScrollListener(state: VerticalScrollState): void {
|
||||||
|
let scrollTimeout: number | undefined;
|
||||||
|
|
||||||
|
state.container.addEventListener('scroll', () => {
|
||||||
|
clearTimeout(scrollTimeout);
|
||||||
|
scrollTimeout = window.setTimeout(() => {
|
||||||
|
checkScrollPosition(state);
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkScrollPosition(state: VerticalScrollState): void {
|
||||||
|
const scrollBottom = state.container.scrollHeight -
|
||||||
|
state.container.scrollTop -
|
||||||
|
state.container.clientHeight;
|
||||||
|
|
||||||
|
if (scrollBottom < state.threshold) {
|
||||||
|
const lastPage = Math.max(...state.loadedPages);
|
||||||
|
if (lastPage < state.totalPages) {
|
||||||
|
loadPage(state, lastPage + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current page based on scroll position
|
||||||
|
const currentPage = getCurrentPageFromScroll(state);
|
||||||
|
if (currentPage !== state.currentPage) {
|
||||||
|
state.currentPage = currentPage;
|
||||||
|
// Dispatch event for progress tracking
|
||||||
|
window.dispatchEvent(new CustomEvent('page-change', {
|
||||||
|
detail: { page: currentPage }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentPageFromScroll(state: VerticalScrollState): number {
|
||||||
|
const pages = state.container.querySelectorAll('.vertical-page');
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
const rect = page.getBoundingClientRect();
|
||||||
|
const containerRect = state.container.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Page is considered "current" if it's in the middle 50% of viewport
|
||||||
|
const pageMiddle = rect.top + rect.height / 2;
|
||||||
|
const viewportMiddle = containerRect.top + containerRect.height / 2;
|
||||||
|
|
||||||
|
if (Math.abs(pageMiddle - viewportMiddle) < containerRect.height / 4) {
|
||||||
|
return parseInt(page.dataset.pageNumber || '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.currentPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyVerticalScroll(state: VerticalScrollState): void {
|
||||||
|
// Clean up blob URLs
|
||||||
|
const images = state.container.querySelectorAll('img');
|
||||||
|
images.forEach(img => {
|
||||||
|
const url = img.src;
|
||||||
|
if (url.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.container.innerHTML = '';
|
||||||
|
state.loadedPages.clear();
|
||||||
|
state.loadingPages.clear();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Reading Direction Detection
|
||||||
|
|
||||||
|
**File:** `web/src/reader/manga/reading-direction.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Detect reading direction from metadata or user preference
|
||||||
|
|
||||||
|
type ReadingDirection = 'auto' | 'ltr' | 'rtl' | 'vertical';
|
||||||
|
|
||||||
|
interface ReadingDirectionState {
|
||||||
|
direction: ReadingDirection;
|
||||||
|
detectedDirection: 'ltr' | 'rtl' | 'vertical';
|
||||||
|
userPreference: ReadingDirection | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectReadingDirection(
|
||||||
|
metadata: MediaItemMetadata
|
||||||
|
): Promise<ReadingDirectionState> {
|
||||||
|
// Check user preference first
|
||||||
|
const userPreference = await getUserReadingDirectionPreference();
|
||||||
|
if (userPreference && userPreference !== 'auto') {
|
||||||
|
return {
|
||||||
|
direction: userPreference,
|
||||||
|
detectedDirection: 'ltr', // Default fallback
|
||||||
|
userPreference
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect from metadata
|
||||||
|
const detectedDirection = detectFromMetadata(metadata);
|
||||||
|
|
||||||
|
return {
|
||||||
|
direction: 'auto',
|
||||||
|
detectedDirection,
|
||||||
|
userPreference: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectFromMetadata(metadata: MediaItemMetadata): 'ltr' | 'rtl' | 'vertical' {
|
||||||
|
// Check manga_type field from database
|
||||||
|
const mangaType = (metadata as any).manga_type;
|
||||||
|
if (mangaType === 'yes_and_right_to_left' || mangaType === 'yes') {
|
||||||
|
return 'rtl';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check reading_direction field
|
||||||
|
const readingDirection = (metadata as any).reading_direction;
|
||||||
|
if (readingDirection === 'rtl' || readingDirection === 'vertical') {
|
||||||
|
return readingDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect from filename
|
||||||
|
const filename = metadata.filePath.toLowerCase();
|
||||||
|
if (filename.includes('manga') || filename.includes('manhwa') || filename.includes('webtoon')) {
|
||||||
|
return 'vertical';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to LTR
|
||||||
|
return 'ltr';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserReadingDirectionPreference(): Promise<ReadingDirection | null> {
|
||||||
|
const userId = localStorage.getItem('userId');
|
||||||
|
if (!userId) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/users/${userId}/settings`);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
|
||||||
|
const settings = await response.json();
|
||||||
|
return settings.reading_direction || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEffectiveDirection(state: ReadingDirectionState): 'ltr' | 'rtl' | 'vertical' {
|
||||||
|
if (state.direction !== 'auto') {
|
||||||
|
return state.direction as 'ltr' | 'rtl' | 'vertical';
|
||||||
|
}
|
||||||
|
return state.detectedDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseRTL(state: ReadingDirectionState): boolean {
|
||||||
|
return getEffectiveDirection(state) === 'rtl';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseVerticalScroll(state: ReadingDirectionState): boolean {
|
||||||
|
return getEffectiveDirection(state) === 'vertical';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 Manga Settings Integration
|
||||||
|
|
||||||
|
**File:** `web/src/reader/manga/settings.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Manga-specific settings integration
|
||||||
|
// Extends the common reader settings manager
|
||||||
|
|
||||||
|
interface MangaSettings {
|
||||||
|
readingDirection: 'auto' | 'ltr' | 'rtl' | 'vertical';
|
||||||
|
verticalScrollSpeed: 'slow' | 'normal' | 'fast';
|
||||||
|
rtlPageTransition: 'slide' | 'fade' | 'none';
|
||||||
|
webtoonMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMangaSettings(): Promise<MangaSettings> {
|
||||||
|
const defaultSettings: MangaSettings = {
|
||||||
|
readingDirection: 'auto',
|
||||||
|
verticalScrollSpeed: 'normal',
|
||||||
|
rtlPageTransition: 'slide',
|
||||||
|
webtoonMode: false
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userId = localStorage.getItem('userId');
|
||||||
|
const response = await fetch(`/api/users/${userId}/settings`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const settings = await response.json();
|
||||||
|
return { ...defaultSettings, ...settings };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load manga settings:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMangaSettings(
|
||||||
|
settings: Partial<MangaSettings>
|
||||||
|
): Promise<void> {
|
||||||
|
const userId = localStorage.getItem('userId');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/users/${userId}/settings`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(settings)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update manga settings');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save manga settings:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMangaSettings(settings: MangaSettings): void {
|
||||||
|
// Apply reading direction
|
||||||
|
document.documentElement.dataset.readingDirection = settings.readingDirection;
|
||||||
|
|
||||||
|
// Apply vertical scroll speed
|
||||||
|
if (settings.verticalScrollSpeed === 'slow') {
|
||||||
|
document.documentElement.style.scrollBehavior = 'smooth';
|
||||||
|
} else if (settings.verticalScrollSpeed === 'fast') {
|
||||||
|
document.documentElement.style.scrollBehavior = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply RTL page transition
|
||||||
|
if (settings.rtlPageTransition !== 'none') {
|
||||||
|
document.documentElement.dataset.pageTransition = settings.rtlPageTransition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.5 Manga Page Cache (Shared with Comics)
|
||||||
|
|
||||||
|
**File:** `web/src/reader/comic/page-cache.ts` (shared for both comics and manga)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Lazy-loading page cache with 5-page ahead prefetch
|
// Lazy-loading page cache with 5-page ahead prefetch
|
||||||
|
// Shared by both comic and manga readers
|
||||||
// Lazy-loading page cache with 5-page ahead prefetch
|
|
||||||
// Procedural implementation (no OOP)
|
|
||||||
|
|
||||||
interface PageCacheState {
|
interface PageCacheState {
|
||||||
cache: Map<number, HTMLImageElement>;
|
cache: Map<number, HTMLImageElement>;
|
||||||
@@ -9123,7 +9518,7 @@ async function getCachedPage(
|
|||||||
resolve({ ...state, page: state.cache.get(pageNumber)! });
|
resolve({ ...state, page: state.cache.get(pageNumber)! });
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}) as Promise<PageCacheState & { page: HTMLImageElement }>;
|
}) as Promise<PageCacheState & { page: HTMLImageElement}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newLoading = new Set(state.loading);
|
const newLoading = new Set(state.loading);
|
||||||
@@ -9140,7 +9535,7 @@ async function getCachedPage(
|
|||||||
prefetchPages(newState, pageNumber + 1);
|
prefetchPages(newState, pageNumber + 1);
|
||||||
cleanupPageCache(newState, pageNumber);
|
cleanupPageCache(newState, pageNumber);
|
||||||
|
|
||||||
return { ...newState, page: img };
|
return { ...state, page: img };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadComicPage(
|
async function loadComicPage(
|
||||||
|
|||||||
Reference in New Issue
Block a user