You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

355 lines
11 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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<ViewerSettings>) => void;
onNavigate: (url: string | null) => void;
onGoHome: () => void;
fetchImage: (url: string, referer: string) => Promise<string>;
isNavigating: boolean;
navError: string | null;
needsAttention: boolean;
}
function buildSlots(images: string[], offset: number): Array<string[]> {
const slots: Array<string[]> = [];
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<ReturnType<typeof setTimeout> | null>(null);
const containerRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
className="manga-viewer"
style={{ backgroundColor: bgMap[settings.backgroundColor] }}
onMouseMove={resetToolbarTimer}
onClick={handleZoneClick}
>
{/* Toolbar */}
<ViewerToolbar
visible={showToolbar}
title={chapter.title}
currentPage={displayPage}
totalPages={totalPages}
currentSlot={slotIndex}
totalSlots={totalSlots}
hasPrev={slotIndex > 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) */}
<button
className={`page-nav-btn page-nav-prev ${!(slotIndex > 0 || !!chapter.prev_chapter) ? 'disabled' : ''}`}
onClick={(e) => { e.stopPropagation(); goPrev(); }}
disabled={!(slotIndex > 0 || !!chapter.prev_chapter)}
title="이전 페이지 (←)"
style={{ opacity: showToolbar ? 1 : 0, pointerEvents: showToolbar ? 'auto' : 'none' }}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M15 18l-6-6 6-6"/>
</svg>
</button>
<button
className={`page-nav-btn page-nav-next ${!(slotIndex < totalSlots - 1 || !!chapter.next_chapter) ? 'disabled' : ''}`}
onClick={(e) => { e.stopPropagation(); goNext(); }}
disabled={!(slotIndex < totalSlots - 1 || !!chapter.next_chapter)}
title="다음 페이지 (→)"
style={{ opacity: showToolbar ? 1 : 0, pointerEvents: showToolbar ? 'auto' : 'none' }}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M9 18l6-6-6-6"/>
</svg>
</button>
{/* Full-screen Loading Overlay */}
{isNavigating && (
<div className="full-loading-overlay" onClick={(e) => e.stopPropagation()}>
<div className="loading-spinner-large" />
<div className="loading-text">
{needsAttention
? '열린 창에서 사이트 인증(캡차/로그인 등)을 완료해주세요.'
: '챕터를 불러오는 중...'}
</div>
</div>
)}
{/* Boundary Warning */}
{boundaryWarning && (
<div className="boundary-warning">
{boundaryWarning === 'first' ? '첫 페이지입니다. 이전 화로 이동하려면 한 번 더 누르세요.' : '마지막 페이지입니다. 다음 화로 이동하려면 한 번 더 누르세요.'}
</div>
)}
{/* Error Toast */}
{!isNavigating && navError && !dismissNavError && (
<div className="nav-status-toast" onClick={(e) => e.stopPropagation()}>
<div className="error-banner">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span>{navError}</span>
</div>
</div>
)}
{/* Page display */}
<div
className={`viewer-stage ${settings.mode === 'spread' && currentSlot.length === 2 ? 'spread-mode' : 'single-mode'}`}
style={{ direction: settings.direction }}
>
{currentSlot.map((imgUrl, idx) => (
<MangaPage
key={imgUrl + idx}
imageUrl={imgUrl}
referer={referer}
fitMode={settings.fitMode}
fetchImage={fetchImage}
/>
))}
</div>
{/* Click zone overlays (visual hints) */}
<div className="click-zones" aria-hidden>
<div className="zone zone-prev" />
<div className="zone zone-middle" />
<div className="zone zone-next" />
</div>
{/* Page progress bar */}
<div className="progress-bar-container">
<div
className="progress-bar"
style={{ width: `${((slotIndex + 1) / totalSlots) * 100}%` }}
/>
</div>
{/* Settings panel */}
{showSettings && (
<SettingsPanel
settings={settings}
totalImages={chapter.images.length}
onChange={onSettingsChange}
onClose={() => setShowSettings(false)}
/>
)}
</div>
);
}