From 43c42be482f395a3eea487f9986898003ea72e86 Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Wed, 22 Jul 2026 15:30:25 +0900 Subject: [PATCH] feat: fix focus stealing on navigation, auto-detect wide images in spread mode, and enhance reading history --- src-tauri/src/commands.rs | 13 +++--- src/App.css | 42 +++++++++++++++++ src/components/MangaViewer.tsx | 78 ++++++++++++++++++++++++++------ src/components/SettingsPanel.tsx | 2 +- src/components/UrlInput.tsx | 63 ++++++++++++++++++-------- src/types.ts | 2 + 6 files changed, 159 insertions(+), 41 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 601af88..961598c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -316,12 +316,9 @@ async fn get_or_create_harvester( ) -> Result { if let Some(win) = app.get_webview_window(HARVESTER_LABEL) { win.navigate(url.clone()).map_err(|e| e.to_string())?; - // The site's own anti-bot appears to gate its image-list request on - // the tab actually being visible (confirmed: a fully hidden window - // gets a 409 on that request instead of real chapter images) — so - // this window must stay visible for the harvest to work, unlike a - // regular headless scrape. - let _ = win.show(); + if let Ok(false) = win.is_visible() { + let _ = win.show(); + } return Ok(win); } @@ -515,6 +512,10 @@ pub async fn open_chapter(app: AppHandle, url: String, req_id: u64) -> Result<() let target = Url::parse(&url).map_err(|e| format!("invalid url: {}", e))?; get_or_create_harvester(&app, &target).await?; + if let Some(main_win) = app.get_webview_window("main") { + let _ = main_win.set_focus(); + } + tauri::async_runtime::spawn(poll_harvest(app, req_id, url)); Ok(()) } diff --git a/src/App.css b/src/App.css index 90f9fdd..1ca7a37 100644 --- a/src/App.css +++ b/src/App.css @@ -393,6 +393,12 @@ kbd { gap: 8px; } +.history-header { + display: flex; + align-items: center; + justify-content: space-between; +} + .history-title { font-size: 12px; font-weight: 600; @@ -401,6 +407,18 @@ kbd { letter-spacing: 0.08em; } +.clear-history-btn { + font-size: 11px; + color: var(--color-text-subtle); + padding: 2px 6px; + border-radius: var(--radius-sm); + cursor: pointer; +} + +.clear-history-btn:hover { + color: var(--color-danger, #ef4444); +} + .history-list { display: flex; flex-direction: column; @@ -427,6 +445,30 @@ kbd { color: var(--color-text); } +.history-info { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.history-manga-title { + font-size: 13px; + font-weight: 600; + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-url-sub { + font-size: 11px; + color: var(--color-text-subtle); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .history-url { flex: 1; font-size: 13px; diff --git a/src/components/MangaViewer.tsx b/src/components/MangaViewer.tsx index ed23de3..e8c0773 100644 --- a/src/components/MangaViewer.tsx +++ b/src/components/MangaViewer.tsx @@ -19,7 +19,11 @@ interface MangaViewerProps { needsAttention: boolean; } -function buildSlots(images: string[], offset: number): Array { +function buildSlots( + images: string[], + offset: number, + dimMap: Record +): Array { const slots: Array = []; let i = 0; @@ -29,11 +33,27 @@ function buildSlots(images: string[], offset: number): Array { i++; } - // Remaining pages: pairs in natural order [Page N, Page N+1] + // Remaining pages: pairs in natural order [Page N, Page N+1], unless wide while (i < images.length) { - if (i + 1 < images.length) { - slots.push([images[i], images[i + 1]]); - i += 2; + const dim = dimMap[images[i]]; + const isWide = dim && dim.width > dim.height * 1.1; + + if (isWide) { + // Full width / landscape image takes a standalone single slot + slots.push([images[i]]); + i++; + } else if (i + 1 < images.length) { + const nextDim = dimMap[images[i + 1]]; + const nextIsWide = nextDim && nextDim.width > nextDim.height * 1.1; + + if (nextIsWide) { + // Current image is normal portrait, but next is wide -> current stands alone + slots.push([images[i]]); + i++; + } else { + slots.push([images[i], images[i + 1]]); + i += 2; + } } else { slots.push([images[i]]); i++; @@ -61,6 +81,7 @@ export function MangaViewer({ const [isFullscreen, setIsFullscreen] = useState(false); const [dismissNavError, setDismissNavError] = useState(false); const [boundaryWarning, setBoundaryWarning] = useState<'first' | 'last' | null>(null); + const [dimMap, setDimMap] = useState>({}); const toolbarTimer = useRef | null>(null); const containerRef = useRef(null); @@ -82,7 +103,7 @@ export function MangaViewer({ const slots = settings.mode === 'spread' - ? buildSlots(chapter.images, settings.spreadStartOffset) + ? buildSlots(chapter.images, settings.spreadStartOffset, dimMap) : chapter.images.map((img) => [img]); const totalSlots = slots.length; @@ -103,17 +124,46 @@ export function MangaViewer({ setBoundaryWarning(null); }, [currentUrl]); - // Preload all images in the background so page turns are instant + // Save/Update reading history with actual chapter title + useEffect(() => { + if (!chapter.title) return; + try { + const saved = JSON.parse(localStorage.getItem('mana-viewer-history') || '[]'); + const filtered = saved.filter((h: any) => h.url !== currentUrl); + const newEntry = { + url: currentUrl, + title: chapter.title, + chapterTitle: chapter.title, + seriesUrl: chapter.series_url, + timestamp: Date.now(), + }; + filtered.unshift(newEntry); + localStorage.setItem('mana-viewer-history', JSON.stringify(filtered.slice(0, 30))); + } catch (e) { + console.error('Failed to update history', e); + } + }, [chapter.title, chapter.series_url, currentUrl]); + + // Preload all images in the background & measure dimensions for wide page detection 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 - } + fetchImage(imgUrl, ref) + .then((dataUri) => { + if (cancelled) return; + const img = new Image(); + img.src = dataUri; + img.onload = () => { + if (cancelled) return; + setDimMap((prev) => { + if (prev[imgUrl]) return prev; + return { ...prev, [imgUrl]: { width: img.naturalWidth, height: img.naturalHeight } }; + }); + }; + }) + .catch(() => {}); }); return () => { cancelled = true; }; }, [chapter.images, currentUrl, fetchImage]); @@ -157,10 +207,10 @@ export function MangaViewer({ useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; - if (e.code === 'ArrowRight') { + if (e.code === 'ArrowRight' || e.code === 'ArrowDown') { e.preventDefault(); goNext(); - } else if (e.code === 'ArrowLeft') { + } else if (e.code === 'ArrowLeft' || e.code === 'ArrowUp') { e.preventDefault(); goPrev(); } else if (e.code === 'KeyF' || e.key === 'f' || e.key === 'F' || e.key === 'ㄹ') { diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 316bcb2..0d0b55d 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -193,7 +193,7 @@ export function SettingsPanel({ settings, totalImages, onChange, onClose }: Sett

키보드 단축키

- / 페이지 이동 + 페이지 이동 F전체화면 토글 S설정 열기/닫기 Esc설정 닫기 diff --git a/src/components/UrlInput.tsx b/src/components/UrlInput.tsx index 9d5fffc..211d802 100644 --- a/src/components/UrlInput.tsx +++ b/src/components/UrlInput.tsx @@ -139,27 +139,50 @@ export function UrlInput({ {/* History */} {history.length > 0 && (
-

최근 기록

+
+

최근 본 만화

+ +
- {history.map((entry) => ( - - ))} + {history.map((entry) => { + const displayTitle = entry.chapterTitle || (entry.title && entry.title !== entry.url ? entry.title : null); + return ( + + ); + })}
)} diff --git a/src/types.ts b/src/types.ts index 7c30b98..399d567 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,5 +24,7 @@ export interface ViewerSettings { export interface HistoryEntry { url: string; title: string; + chapterTitle?: string | null; + seriesUrl?: string | null; timestamp: number; }