import { useState, useEffect, useCallback, useRef } from 'react'; import type { ViewerSettings } from '../types'; import type { ChapterData } from '../types'; import { MangaPage } from './MangaPage'; import { ViewerToolbar } from './ViewerToolbar'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { SettingsPanel } from './SettingsPanel'; interface MangaViewerProps { chapter: ChapterData; currentUrl: string; settings: ViewerSettings; onSettingsChange: (s: Partial) => void; onNavigate: (url: string | null) => void; onGoHome: () => void; fetchImage: (url: string, referer: string) => Promise; isNavigating: boolean; navError: string | null; needsAttention: boolean; } function buildSlots(images: string[], offset: number): Array { const slots: Array = []; let i = 0; // Pages before offset: single while (i < offset && i < images.length) { slots.push([images[i]]); i++; } // Remaining pages: pairs in natural order [Page N, Page N+1] while (i < images.length) { if (i + 1 < images.length) { slots.push([images[i], images[i + 1]]); i += 2; } else { slots.push([images[i]]); i++; } } return slots; } export function MangaViewer({ chapter, currentUrl, settings, onSettingsChange, onNavigate, onGoHome, fetchImage, isNavigating, navError, needsAttention, }: MangaViewerProps) { const [slotIndex, setSlotIndex] = useState(0); const [showToolbar, setShowToolbar] = useState(true); const [showSettings, setShowSettings] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [dismissNavError, setDismissNavError] = useState(false); const [boundaryWarning, setBoundaryWarning] = useState<'first' | 'last' | null>(null); const toolbarTimer = useRef | null>(null); const containerRef = useRef(null); useEffect(() => { setDismissNavError(false); if (!navError) return; const t = setTimeout(() => setDismissNavError(true), 6000); return () => clearTimeout(t); }, [navError]); useEffect(() => { const handleFullscreenChange = () => { setIsFullscreen(!!document.fullscreenElement); }; document.addEventListener('fullscreenchange', handleFullscreenChange); return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); }, []); const slots = settings.mode === 'spread' ? buildSlots(chapter.images, settings.spreadStartOffset) : chapter.images.map((img) => [img]); const totalSlots = slots.length; const currentSlot = slots[slotIndex] ?? []; // Compute global page numbers for display const firstPageNum = slotIndex === 0 ? (settings.mode === 'spread' ? 0 : 0) : slots.slice(0, slotIndex).reduce((acc, s) => acc + s.length, 0); const displayPage = firstPageNum + 1; const totalPages = chapter.images.length; // Reset state on new chapter useEffect(() => { setSlotIndex(0); setShowSettings(false); setBoundaryWarning(null); }, [currentUrl]); // Preload all images in the background so page turns are instant useEffect(() => { if (!chapter.images.length) return; let cancelled = false; const ref = new URL(currentUrl).origin; // Fire off all fetches concurrently; fetchImage caches results // so subsequent MangaPage renders will get instant cache hits. chapter.images.forEach((imgUrl) => { if (!cancelled) { fetchImage(imgUrl, ref).catch(() => {}); // swallow errors – page will retry on its own } }); return () => { cancelled = true; }; }, [chapter.images, currentUrl, fetchImage]); const goNext = useCallback(() => { if (slotIndex < totalSlots - 1) { setSlotIndex((i) => i + 1); setBoundaryWarning(null); } else if (chapter.next_chapter) { if (boundaryWarning === 'last') { onNavigate(chapter.next_chapter); } else { setBoundaryWarning('last'); } } }, [slotIndex, totalSlots, chapter.next_chapter, boundaryWarning, onNavigate]); const goPrev = useCallback(() => { if (slotIndex > 0) { setSlotIndex((i) => i - 1); setBoundaryWarning(null); } else if (chapter.prev_chapter) { if (boundaryWarning === 'first') { onNavigate(chapter.prev_chapter); } else { setBoundaryWarning('first'); } } }, [slotIndex, chapter.prev_chapter, boundaryWarning, onNavigate]); // Keyboard navigation useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; switch (e.key) { case 'ArrowRight': e.preventDefault(); goNext(); break; case 'ArrowLeft': e.preventDefault(); goPrev(); break; case 'f': case 'F': toggleFullscreen(); break; case 's': case 'S': setShowSettings((v) => !v); break; case 'Escape': if (showSettings) setShowSettings(false); break; } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [goNext, goPrev, showSettings, settings.direction, toggleFullscreen]); // Auto-hide toolbar const resetToolbarTimer = useCallback(() => { setShowToolbar(true); if (toolbarTimer.current) clearTimeout(toolbarTimer.current); toolbarTimer.current = setTimeout(() => { if (!showSettings) setShowToolbar(false); }, 3000); }, [showSettings]); useEffect(() => { resetToolbarTimer(); return () => { if (toolbarTimer.current) clearTimeout(toolbarTimer.current); }; }, []); const toggleFullscreen = useCallback(async () => { try { const win = getCurrentWindow(); const current = await win.isFullscreen(); const nextState = !current; await win.setFullscreen(nextState); setIsFullscreen(nextState); } catch (e) { console.error('Failed to toggle fullscreen', e); } }, []); const bgMap = { black: '#000000', white: '#f8f8f8', gray: '#1a1a1a', }; // Click zones: left third = prev, right third = next, middle = toggle toolbar const handleZoneClick = (e: React.MouseEvent) => { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const x = e.clientX - rect.left; const third = rect.width / 3; if (x < third) { goPrev(); } else if (x > third * 2) { goNext(); } else { setShowToolbar((v) => !v); } }; const referer = new URL(currentUrl).origin; return (
{/* Toolbar */} 0 || !!chapter.prev_chapter} hasNext={slotIndex < totalSlots - 1 || !!chapter.next_chapter} settings={settings} isFullscreen={isFullscreen} onPrevChapter={() => onNavigate(chapter.prev_chapter)} onNextChapter={() => onNavigate(chapter.next_chapter)} onToggleSettings={() => setShowSettings((v) => !v)} onToggleFullscreen={toggleFullscreen} onGoHome={onGoHome} onSettingsChange={onSettingsChange} /> {/* Page navigation arrows (floating in center) */} {/* Full-screen Loading Overlay */} {isNavigating && (
e.stopPropagation()}>
{needsAttention ? '열린 창에서 사이트 인증(캡차/로그인 등)을 완료해주세요.' : '챕터를 불러오는 중...'}
)} {/* Boundary Warning */} {boundaryWarning && (
{boundaryWarning === 'first' ? '첫 페이지입니다. 이전 화로 이동하려면 한 번 더 누르세요.' : '마지막 페이지입니다. 다음 화로 이동하려면 한 번 더 누르세요.'}
)} {/* Error Toast */} {!isNavigating && navError && !dismissNavError && (
e.stopPropagation()}>
{navError}
)} {/* Page display */}
{currentSlot.map((imgUrl, idx) => ( ))}
{/* Click zone overlays (visual hints) */}
{/* Page progress bar */}
{/* Settings panel */} {showSettings && ( setShowSettings(false)} /> )}
); }