feat: fix focus stealing on navigation, auto-detect wide images in spread mode, and enhance reading history

main
I Luk Kim 3 weeks ago
parent e4269041ee
commit 43c42be482

@ -316,12 +316,9 @@ async fn get_or_create_harvester(
) -> Result<tauri::WebviewWindow, String> {
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.
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(())
}

@ -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;

@ -19,7 +19,11 @@ interface MangaViewerProps {
needsAttention: boolean;
}
function buildSlots(images: string[], offset: number): Array<string[]> {
function buildSlots(
images: string[],
offset: number,
dimMap: Record<string, { width: number; height: number }>
): Array<string[]> {
const slots: Array<string[]> = [];
let i = 0;
@ -29,11 +33,27 @@ function buildSlots(images: string[], offset: number): Array<string[]> {
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) {
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<Record<string, { width: number; height: number }>>({});
const toolbarTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const containerRef = useRef<HTMLDivElement>(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 === 'ㄹ') {

@ -193,7 +193,7 @@ export function SettingsPanel({ settings, totalImages, onChange, onClose }: Sett
<div className="settings-footer">
<h3 className="shortcuts-title"> </h3>
<div className="shortcuts-grid">
<span><kbd></kbd> / <kbd></kbd></span><span> </span>
<span><kbd></kbd> <kbd></kbd> <kbd></kbd> <kbd></kbd></span><span> </span>
<span><kbd>F</kbd></span><span> </span>
<span><kbd>S</kbd></span><span> /</span>
<span><kbd>Esc</kbd></span><span> </span>

@ -139,9 +139,22 @@ export function UrlInput({
{/* History */}
{history.length > 0 && (
<div className="history-section">
<h3 className="history-title"> </h3>
<div className="history-header">
<h3 className="history-title"> </h3>
<button
className="btn-ghost clear-history-btn"
onClick={() => {
localStorage.removeItem(HISTORY_KEY);
setHistory([]);
}}
>
</button>
</div>
<div className="history-list">
{history.map((entry) => (
{history.map((entry) => {
const displayTitle = entry.chapterTitle || (entry.title && entry.title !== entry.url ? entry.title : null);
return (
<button
key={entry.url}
className="history-item"
@ -154,12 +167,22 @@ export function UrlInput({
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8z"/>
<path d="M12 6v6l4 2"/>
</svg>
<div className="history-info">
{displayTitle ? (
<>
<span className="history-manga-title">{displayTitle}</span>
<span className="history-url-sub">{entry.url}</span>
</>
) : (
<span className="history-url">{entry.url}</span>
)}
</div>
<span className="history-time">
{new Date(entry.timestamp).toLocaleDateString('ko-KR')}
</span>
</button>
))}
);
})}
</div>
</div>
)}

@ -24,5 +24,7 @@ export interface ViewerSettings {
export interface HistoryEntry {
url: string;
title: string;
chapterTitle?: string | null;
seriesUrl?: string | null;
timestamp: number;
}

Loading…
Cancel
Save