mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
660 lines
28 KiB
Svelte
660 lines
28 KiB
Svelte
<script lang="ts">
|
|
import { onMount, untrack, tick } from "svelte";
|
|
import { readerState, PAGE_STYLES } from "$lib/state/reader.svelte";
|
|
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
|
import { DEFAULT_KEYBINDS } from "$lib/core/keybinds/defaultBinds";
|
|
import { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups } from "$lib/components/reader/lib/pageLoader";
|
|
import { setupScrollTracking, appendNextChapter } from "$lib/components/reader/lib/scrollHandler";
|
|
import { createReaderKeyHandler } from "$lib/components/reader/lib/readerKeybinds";
|
|
import { markChapterRead, getMangaPrefs, toggleBookmark } from "$lib/components/reader/lib/chapterActions";
|
|
import { goForward, goBack, jumpToPage } from "$lib/components/reader/lib/navigation";
|
|
import { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "$lib/components/reader/lib/zoomHelpers";
|
|
import { loadChapter, scheduleResumeDismiss } from "$lib/components/reader/lib/chapterLoader";
|
|
import type { ReaderSettings } from "$lib/state/reader.svelte";
|
|
import ReaderControls from "$lib/components/reader/ReaderControls.svelte";
|
|
import PageView from "$lib/components/reader/PageView.svelte";
|
|
import ReaderProgressBar from "$lib/components/reader/ReaderProgressBar.svelte";
|
|
import ReaderOverlay from "$lib/components/reader/ReaderOverlay.svelte";
|
|
import ReaderPresetPanel from "$lib/components/reader/ReaderPresetPanel.svelte";
|
|
|
|
const useBlob = $derived((settingsState.settings.serverAuthMode ?? "NONE") !== "NONE");
|
|
|
|
const effectiveReaderSettings = $derived.by(() => {
|
|
const mangaId = readerState.activeManga?.id;
|
|
const override = mangaId != null ? (settingsState.settings.mangaReaderSettings ?? {})[mangaId] : undefined;
|
|
return override ? { ...settingsState.settings, ...override } : settingsState.settings;
|
|
});
|
|
|
|
const rtl = $derived(effectiveReaderSettings.readingDirection === "rtl");
|
|
const fit = $derived((effectiveReaderSettings.fitMode ?? "width") as ReaderSettings["fitMode"]);
|
|
const style = $derived((effectiveReaderSettings.pageStyle ?? "single") as typeof PAGE_STYLES[number]);
|
|
const zoom = $derived(effectiveReaderSettings.readerZoom ?? 1.0);
|
|
const markOnNext = $derived(settingsState.settings.markReadOnNext ?? true);
|
|
const overlayBars = $derived(settingsState.settings.overlayBars ?? false);
|
|
const tapToToggleBar = $derived(settingsState.settings.tapToToggleBar ?? false);
|
|
const barPosition = $derived((settingsState.settings.barPosition ?? "top") as "top" | "left" | "right");
|
|
const isVerticalBar = $derived(barPosition === "left" || barPosition === "right");
|
|
const lastPage = $derived(readerState.pageUrls.length);
|
|
const effectiveWidth = $derived(readerState.containerWidth > 0 ? Math.round(readerState.containerWidth * zoom) : undefined);
|
|
const zoomPct = $derived(Math.round(zoom * 100));
|
|
const pinchZoomEnabled = $derived(settingsState.settings.pinchZoom ?? false);
|
|
const containerized = $derived(settingsState.settings.readerContainerized ?? false);
|
|
|
|
const displayChapter = $derived(
|
|
style === "longstrip" && readerState.visibleChapterId
|
|
? (readerState.activeChapterList.find(c => c.id === readerState.visibleChapterId) ?? readerState.activeChapter)
|
|
: readerState.activeChapter
|
|
);
|
|
|
|
const currentBookmark = $derived(
|
|
readerState.activeManga
|
|
? readerState.bookmarks.find(b => b.mangaId === readerState.activeManga!.id)
|
|
: undefined
|
|
);
|
|
const isBookmarked = $derived(
|
|
!!currentBookmark &&
|
|
currentBookmark.chapterId === displayChapter?.id &&
|
|
currentBookmark.pageNumber === readerState.pageNumber
|
|
);
|
|
|
|
const currentPageMarkers = $derived(displayChapter ? readerState.getMarkersForPage(displayChapter.id, readerState.pageNumber) : []);
|
|
const activeChapterMarkers = $derived(displayChapter ? readerState.getMarkersForChapter(displayChapter.id) : []);
|
|
const hasMarkerOnPage = $derived(currentPageMarkers.length > 0);
|
|
|
|
const showResumeBanner = $derived(
|
|
readerState.resumeVisible && readerState.resumePage > 1 &&
|
|
(style === "longstrip" ? readerState.stripResumeReady : readerState.pageNumber === readerState.resumePage)
|
|
);
|
|
|
|
const adjacent = $derived.by(() => {
|
|
const ref = displayChapter ?? readerState.activeChapter;
|
|
if (!ref || !readerState.activeChapterList.length) return { prev: null, next: null, remaining: [] };
|
|
const idx = readerState.activeChapterList.findIndex(c => c.id === ref.id);
|
|
return {
|
|
prev: idx > 0 ? readerState.activeChapterList[idx - 1] : null,
|
|
next: idx < readerState.activeChapterList.length - 1 ? readerState.activeChapterList[idx + 1] : null,
|
|
remaining: readerState.activeChapterList.slice(idx + 1),
|
|
};
|
|
});
|
|
|
|
const visibleChunkLastPage = $derived.by(() => {
|
|
if (style !== "longstrip") return lastPage;
|
|
const chId = readerState.visibleChapterId ?? readerState.activeChapter?.id;
|
|
const chunk = readerState.stripChapters.find(c => c.chapterId === chId);
|
|
return chunk?.urls.length ?? lastPage;
|
|
});
|
|
|
|
const imgCls = $derived([
|
|
"img",
|
|
fit === "width" && "fit-width",
|
|
fit === "height" && "fit-height",
|
|
fit === "screen" && "fit-screen",
|
|
fit === "original" && "fit-original",
|
|
effectiveReaderSettings.optimizeContrast && "optimize-contrast",
|
|
].filter(Boolean).join(" "));
|
|
|
|
const stripToRender = $derived(
|
|
style === "longstrip"
|
|
? (readerState.stripChapters.length > 0
|
|
? readerState.stripChapters
|
|
: [{ chapterId: readerState.activeChapter?.id ?? 0, chapterName: readerState.activeChapter?.name ?? "", urls: readerState.pageUrls }])
|
|
: []
|
|
);
|
|
|
|
const currentGroup = $derived.by(() => {
|
|
const group = style === "double" && readerState.pageGroups.length
|
|
? (readerState.pageGroups.find(g => g.includes(readerState.pageNumber)) ?? [readerState.pageNumber])
|
|
: [readerState.pageNumber];
|
|
return rtl ? [...group].reverse() : group;
|
|
});
|
|
|
|
const sliderPage = $derived.by(() => {
|
|
if (style === "double" && readerState.pageGroups.length)
|
|
return readerState.pageGroups.findIndex(g => g.includes(readerState.pageNumber)) + 1;
|
|
return readerState.pageNumber;
|
|
});
|
|
|
|
const sliderMax = $derived.by(() => {
|
|
if (style === "double" && readerState.pageGroups.length) return readerState.pageGroups.length;
|
|
if (style === "longstrip") return visibleChunkLastPage || 1;
|
|
return lastPage || 1;
|
|
});
|
|
|
|
const sliderPctRaw = $derived(sliderMax > 1 ? ((sliderPage - 1) / (sliderMax - 1)) * 100 : 0);
|
|
const sliderPct = $derived(rtl ? 100 - sliderPctRaw : sliderPctRaw);
|
|
|
|
const perMangaEnabled = $derived(
|
|
readerState.activeManga?.id != null &&
|
|
!!(settingsState.settings.mangaReaderSettings ?? {})[readerState.activeManga.id]
|
|
);
|
|
|
|
let containerEl: HTMLDivElement | null = null;
|
|
let pageViewRef: PageView;
|
|
let zoomAnchor = { el: null as HTMLElement | null, offset: 0 };
|
|
let hideTimer = $state<ReturnType<typeof setTimeout> | null>(null);
|
|
let markedRead = new Set<number>();
|
|
let appending = false;
|
|
let abortCtrl = { current: null as AbortController | null };
|
|
let hasNavigated = false;
|
|
let startAtLastPageRef = { current: false };
|
|
let cleanupScroll: () => void = () => {};
|
|
let stripChaptersRef = readerState.stripChapters;
|
|
|
|
$effect(() => { stripChaptersRef = readerState.stripChapters; });
|
|
|
|
function maybeMarkCurrentRead() {
|
|
const ch = displayChapter ?? readerState.activeChapter;
|
|
if (ch && markOnNext) markChapterRead(ch.id, markedRead);
|
|
}
|
|
|
|
function commitMarkerAction() {
|
|
const ch = displayChapter;
|
|
const manga = readerState.activeManga;
|
|
if (!ch || !manga) return;
|
|
if (readerState.markerEditId) {
|
|
readerState.updateMarker(readerState.markerEditId, { note: readerState.markerNote.trim(), color: readerState.markerColor });
|
|
} else {
|
|
readerState.addMarker({
|
|
mangaId: manga.id, mangaTitle: manga.title, thumbnailUrl: manga.thumbnailUrl,
|
|
chapterId: ch.id, chapterName: ch.name,
|
|
pageNumber: readerState.pageNumber, note: readerState.markerNote.trim(), color: readerState.markerColor,
|
|
});
|
|
}
|
|
readerState.clearMarkerPopover();
|
|
}
|
|
|
|
function deleteCurrentMarker() {
|
|
if (readerState.markerEditId) readerState.removeMarker(readerState.markerEditId);
|
|
readerState.clearMarkerPopover();
|
|
}
|
|
|
|
function showUi() {
|
|
readerState.uiVisible = true;
|
|
if (hideTimer) clearTimeout(hideTimer);
|
|
if (!tapToToggleBar) {
|
|
hideTimer = setTimeout(() => {
|
|
if (!readerState.markerOpen && !readerState.winOpen) readerState.uiVisible = false;
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
function toggleUiVisibility() {
|
|
if (readerState.uiVisible) {
|
|
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
|
|
readerState.uiVisible = false;
|
|
} else {
|
|
readerState.uiVisible = true;
|
|
}
|
|
}
|
|
|
|
function handleTap(e: MouseEvent) {
|
|
const x = e.clientX / window.innerWidth;
|
|
if (x > 0.6) goNext(); else if (x < 0.4) goPrev();
|
|
}
|
|
|
|
function handleWheel(e: WheelEvent) {
|
|
if (!e.ctrlKey) return;
|
|
e.preventDefault();
|
|
captureZoomAnchor(containerEl, style, zoomAnchor);
|
|
applySettings({ readerZoom: clampZoom(zoom + (e.deltaY < 0 ? 0.05 : -0.05)) });
|
|
restoreZoomAnchor(containerEl, zoomAnchor);
|
|
}
|
|
|
|
const startAtLast = () => { startAtLastPageRef.current = true; };
|
|
|
|
const goNext = $derived(rtl
|
|
? () => goBack(style, adjacent, startAtLast)
|
|
: () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast));
|
|
const goPrev = $derived(rtl
|
|
? () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast)
|
|
: () => goBack(style, adjacent, startAtLast));
|
|
|
|
const onKey = createReaderKeyHandler({
|
|
goNext: () => goNext(),
|
|
goPrev: () => goPrev(),
|
|
closeReader: () => readerState.closeReader(),
|
|
goToPage: (p) => jumpToPage(p, style, lastPage, containerEl),
|
|
lastPage: () => lastPage,
|
|
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
|
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
|
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
|
|
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
|
|
openSettings: () => { settingsState.settingsOpen = true; },
|
|
toggleBookmark: () => toggleBookmark(displayChapter, readerState.pageNumber),
|
|
toggleAutoScroll: () => { if (style === "longstrip") updateSettings({ autoScroll: !(settingsState.settings.autoScroll ?? false) }); },
|
|
toggleMarker: () => {
|
|
if (currentPageMarkers.length > 0) {
|
|
const first = currentPageMarkers[0];
|
|
readerState.openMarker(first.id, first.note, first.color);
|
|
} else {
|
|
readerState.openMarker("", "", "yellow");
|
|
}
|
|
},
|
|
chapterNext: () => {
|
|
const ch = rtl ? adjacent.prev : adjacent.next;
|
|
if (ch) { maybeMarkCurrentRead(); readerState.openReader(ch, readerState.activeChapterList); }
|
|
},
|
|
chapterPrev: () => {
|
|
const ch = rtl ? adjacent.next : adjacent.prev;
|
|
if (ch) readerState.openReader(ch, readerState.activeChapterList);
|
|
},
|
|
closePopovers: () => readerState.closeAllPopovers(),
|
|
getKeybinds: () => settingsState.settings.keybinds ?? DEFAULT_KEYBINDS,
|
|
});
|
|
|
|
function bindContainer(el: HTMLDivElement) { containerEl = el; }
|
|
|
|
function captureCurrentReaderSettings(): ReaderSettings {
|
|
return {
|
|
pageStyle: style,
|
|
fitMode: fit,
|
|
readingDirection: (settingsState.settings.readingDirection ?? "ltr") as ReaderSettings["readingDirection"],
|
|
readerZoom: zoom,
|
|
pageGap: effectiveReaderSettings.pageGap ?? true,
|
|
optimizeContrast: effectiveReaderSettings.optimizeContrast ?? false,
|
|
offsetDoubleSpreads: effectiveReaderSettings.offsetDoubleSpreads ?? false,
|
|
};
|
|
}
|
|
|
|
function applySettings(patch: Partial<ReaderSettings>) {
|
|
const mangaId = readerState.activeManga?.id;
|
|
if (mangaId != null && (settingsState.settings.mangaReaderSettings ?? {})[mangaId]) {
|
|
readerState.setMangaReaderSettings(mangaId, { ...(settingsState.settings.mangaReaderSettings ?? {})[mangaId]!, ...patch });
|
|
} else {
|
|
updateSettings(patch);
|
|
}
|
|
}
|
|
|
|
function handleTogglePerManga() {
|
|
const mangaId = readerState.activeManga?.id;
|
|
if (mangaId == null) return;
|
|
if ((settingsState.settings.mangaReaderSettings ?? {})[mangaId]) {
|
|
readerState.clearMangaReaderSettings(mangaId);
|
|
} else {
|
|
readerState.setMangaReaderSettings(mangaId, captureCurrentReaderSettings());
|
|
}
|
|
}
|
|
|
|
function handleSavePreset(name: string) {
|
|
readerState.saveReaderPreset(name, captureCurrentReaderSettings());
|
|
}
|
|
|
|
function handleApplyPreset(settings: ReaderSettings) {
|
|
const mangaId = readerState.activeManga?.id;
|
|
if (mangaId != null && (settingsState.settings.mangaReaderSettings ?? {})[mangaId]) {
|
|
readerState.setMangaReaderSettings(mangaId, settings);
|
|
} else {
|
|
updateSettings(settings);
|
|
}
|
|
}
|
|
|
|
function handleBarPositionChange(pos: "top" | "left" | "right") {
|
|
updateSettings({ barPosition: pos });
|
|
}
|
|
|
|
$effect(() => {
|
|
const ch = readerState.activeChapter;
|
|
if (ch) untrack(() => loadChapter(ch.id, useBlob, abortCtrl, startAtLastPageRef, markedRead, adjacent));
|
|
});
|
|
|
|
$effect(() => {
|
|
if (style === "longstrip" && readerState.pageUrls.length && readerState.activeChapter) {
|
|
const ch = readerState.activeChapter;
|
|
const urls = readerState.pageUrls;
|
|
const targetPg = untrack(() => readerState.resumePage);
|
|
appending = false;
|
|
readerState.stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
|
|
readerState.visibleChapterId = ch.id;
|
|
tick().then(() => {
|
|
if (!containerEl) return;
|
|
if (targetPg > 1) {
|
|
const chId = ch.id;
|
|
const scrollToResumePage = () => {
|
|
const target = containerEl!.querySelector<HTMLImageElement>(`img[data-local-page="${targetPg}"][data-chapter="${chId}"]`);
|
|
if (!target) { requestAnimationFrame(scrollToResumePage); return; }
|
|
containerEl!.querySelectorAll<HTMLImageElement>(`img[data-chapter="${chId}"]`).forEach((img, i) => { if (i < targetPg) img.loading = "eager"; });
|
|
const doScroll = () => { target.scrollIntoView({ block: "start" }); readerState.stripResumeReady = true; };
|
|
if (target.complete && target.naturalHeight > 0) doScroll();
|
|
else { target.loading = "eager"; target.addEventListener("load", doScroll, { once: true }); }
|
|
};
|
|
scrollToResumePage();
|
|
return;
|
|
}
|
|
containerEl!.scrollTop = 0;
|
|
});
|
|
}
|
|
});
|
|
|
|
$effect(() => { if (style !== "longstrip") readerState.resetInspect(); });
|
|
|
|
$effect(() => {
|
|
const chId = readerState.visibleChapterId;
|
|
if (!chId || style !== "longstrip") return;
|
|
if (chId === readerState.activeChapter?.id) return;
|
|
const wasAppended = untrack(() => readerState.stripChapters.findIndex(c => c.chapterId === chId)) > 0;
|
|
if (wasAppended) {
|
|
untrack(() => {
|
|
readerState.resumePage = 0;
|
|
readerState.resumeVisible = false;
|
|
const prefs = getMangaPrefs(chId);
|
|
if (prefs.downloadAhead > 0) {
|
|
const list = readerState.activeChapterList;
|
|
const idx = list.findIndex(c => c.id === chId);
|
|
if (idx >= 0) {
|
|
const toQueue = list.slice(idx + 1, idx + 1 + prefs.downloadAhead)
|
|
.filter(c => !c.downloaded && !c.read)
|
|
.map(c => c.id);
|
|
if (toQueue.length) {
|
|
const DL = `mutation EnqueueDl($ids: [Int!]!) { enqueueChaptersDownloads(input: { ids: $ids }) { downloadStatus { queue { chapter { id } } } } }`;
|
|
const base = settingsState.settings.serverUrl ?? "http://localhost:4567";
|
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
const mode = settingsState.settings.serverAuthMode ?? "NONE";
|
|
if (mode === "BASIC_AUTH") {
|
|
const u = settingsState.settings.serverAuthUser?.trim() ?? "";
|
|
const p = settingsState.settings.serverAuthPass?.trim() ?? "";
|
|
if (u && p) headers["Authorization"] = `Basic ${btoa(`${u}:${p}`)}`;
|
|
}
|
|
fetch(`${base}/api/graphql`, { method: "POST", headers, body: JSON.stringify({ query: DL, variables: { ids: toQueue } }) })
|
|
.catch(console.error);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
const bookmark = readerState.bookmarks.find(b => b.chapterId === chId);
|
|
if (bookmark && bookmark.pageNumber > 1) {
|
|
untrack(() => {
|
|
readerState.resumePage = bookmark.pageNumber;
|
|
readerState.resumeDismissed = false;
|
|
readerState.resumeVisible = true;
|
|
readerState.stripResumeReady = true;
|
|
scheduleResumeDismiss();
|
|
});
|
|
} else {
|
|
untrack(() => readerState.resetResume());
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
void style;
|
|
if (!containerEl) return;
|
|
untrack(() => {
|
|
cleanupScroll();
|
|
cleanupScroll = setupScrollTracking(containerEl!, {
|
|
onPageChange: (p) => { readerState.pageNumber = p; },
|
|
onChapterChange: (id) => { readerState.visibleChapterId = id; },
|
|
onMarkRead: (id) => markChapterRead(id, markedRead),
|
|
onAppend: () => {
|
|
if (appending || !readerState.stripChapters.length) return;
|
|
appending = true;
|
|
appendNextChapter(
|
|
stripChaptersRef,
|
|
readerState.activeChapterList,
|
|
(id) => fetchPages(id, useBlob),
|
|
(url) => preloadImage(url, useBlob),
|
|
(next) => { readerState.stripChapters = [...readerState.stripChapters, next]; appending = false; },
|
|
() => { appending = false; },
|
|
);
|
|
},
|
|
getStripChapters: () => stripChaptersRef,
|
|
getPageUrls: () => readerState.pageUrls,
|
|
shouldAutoMark: () => settingsState.settings.autoMarkRead ?? true,
|
|
});
|
|
});
|
|
});
|
|
|
|
$effect(() => {
|
|
if (readerState.activeChapter && readerState.activeChapterList.length) {
|
|
const idx = readerState.activeChapterList.findIndex(c => c.id === readerState.activeChapter!.id);
|
|
if (idx >= 0) {
|
|
const next = readerState.activeChapterList[idx + 1];
|
|
const prev = readerState.activeChapterList[idx - 1];
|
|
if (next) fetchPages(next.id, useBlob).then(urls => urls.slice(0, 8).forEach(u => preloadImage(u, useBlob))).catch(() => {});
|
|
if (prev) fetchPages(prev.id, useBlob).then(urls => urls.slice(0, 2).forEach(u => preloadImage(u, useBlob))).catch(() => {});
|
|
}
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (style === "double" && readerState.pageUrls.length) {
|
|
let cancelled = false;
|
|
const snap = readerState.pageUrls;
|
|
Promise.all(snap.map(url => measureAspect(url, useBlob))).then(aspects => {
|
|
if (cancelled || snap !== readerState.pageUrls) return;
|
|
readerState.pageGroups = buildPageGroups(snap, aspects, effectiveReaderSettings.offsetDoubleSpreads ?? false);
|
|
});
|
|
return () => { cancelled = true; };
|
|
} else {
|
|
readerState.pageGroups = [];
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
const ahead = settingsState.settings.preloadPages ?? 3;
|
|
const current = readerState.pageUrls[readerState.pageNumber - 1];
|
|
const pageNum = readerState.pageNumber;
|
|
const urls = readerState.pageUrls;
|
|
if (!current) return;
|
|
const t = setTimeout(() => {
|
|
if (useBlob) {
|
|
import("$lib/core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => {
|
|
getBlobUrl(current, 999);
|
|
const upcoming = Array.from({ length: ahead }, (_, i) => urls[pageNum + i]).filter(Boolean) as string[];
|
|
const behind = urls[pageNum - 2];
|
|
preloadBlobUrls(upcoming, ahead);
|
|
if (behind) preloadBlobUrls([behind], 0);
|
|
});
|
|
} else {
|
|
for (let i = 1; i <= ahead; i++) {
|
|
const url = urls[pageNum - 1 + i];
|
|
if (url) preloadImage(url, useBlob);
|
|
}
|
|
const behind = urls[pageNum - 2];
|
|
if (behind) preloadImage(behind, useBlob);
|
|
}
|
|
}, 150);
|
|
return () => clearTimeout(t);
|
|
});
|
|
|
|
$effect(() => {
|
|
if (readerState.markerOpen || readerState.winOpen) {
|
|
readerState.uiVisible = true;
|
|
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (tapToToggleBar) {
|
|
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
|
|
readerState.uiVisible = true;
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
const ch = displayChapter ?? readerState.activeChapter;
|
|
const manga = readerState.activeManga;
|
|
if (ch && lastPage && manga) {
|
|
const { id: chapterId, name: chapterName } = ch;
|
|
const { id: mangaId, title: mangaTitle, thumbnailUrl: thumb } = manga;
|
|
const pageNum = readerState.pageNumber;
|
|
const atLast = pageNum === lastPage;
|
|
if (pageNum > 1) hasNavigated = true;
|
|
untrack(() => {
|
|
if (!hasNavigated) return;
|
|
if (style === "longstrip" && readerState.visibleChapterId && chapterId !== readerState.visibleChapterId) return;
|
|
if (settingsState.settings.autoBookmark ?? true) {
|
|
const existing = readerState.bookmarks.find(b => b.mangaId === mangaId && b.chapterId !== chapterId);
|
|
if (existing) readerState.removeBookmark(existing.chapterId);
|
|
readerState.addBookmark({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum });
|
|
}
|
|
if (style !== "longstrip" && (settingsState.settings.autoMarkRead ?? true) && atLast) markChapterRead(chapterId, markedRead);
|
|
});
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
function onFsChange() { readerState.isFullscreen = !!document.fullscreenElement; }
|
|
document.addEventListener("fullscreenchange", onFsChange);
|
|
return () => document.removeEventListener("fullscreenchange", onFsChange);
|
|
});
|
|
|
|
onMount(() => {
|
|
showUi();
|
|
window.addEventListener("keydown", onKey);
|
|
window.addEventListener("mousemove", pageViewRef.onInspectMouseMove);
|
|
window.addEventListener("mouseup", pageViewRef.onInspectMouseUp);
|
|
window.addEventListener("pointermove", pageViewRef.onPointerMove);
|
|
window.addEventListener("pointerup", pageViewRef.onPointerUp);
|
|
|
|
readerState.isFullscreen = !!document.fullscreenElement;
|
|
|
|
let roTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const ro = new ResizeObserver(entries => {
|
|
const w = entries[0].contentRect.width;
|
|
if (roTimer) clearTimeout(roTimer);
|
|
roTimer = setTimeout(() => { readerState.containerWidth = w; roTimer = null; }, 50);
|
|
});
|
|
if (containerEl) ro.observe(containerEl);
|
|
|
|
return () => {
|
|
abortCtrl.current?.abort();
|
|
if (hideTimer) clearTimeout(hideTimer);
|
|
if (roTimer) clearTimeout(roTimer);
|
|
window.removeEventListener("keydown", onKey);
|
|
window.removeEventListener("mousemove", pageViewRef.onInspectMouseMove);
|
|
window.removeEventListener("mouseup", pageViewRef.onInspectMouseUp);
|
|
window.removeEventListener("pointermove", pageViewRef.onPointerMove);
|
|
window.removeEventListener("pointerup", pageViewRef.onPointerUp);
|
|
cleanupScroll();
|
|
ro.disconnect();
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<div
|
|
class="root"
|
|
class:containerized
|
|
class:overlay-bars={overlayBars}
|
|
class:bar-left={barPosition === "left"}
|
|
class:bar-right={barPosition === "right"}
|
|
class:pinch-active={pinchZoomEnabled}
|
|
role="presentation"
|
|
onmousemove={(e) => {
|
|
if (!tapToToggleBar) {
|
|
if (barPosition === "top" && (e.clientY < 60 || window.innerHeight - e.clientY < 60)) showUi();
|
|
if (barPosition === "left" && e.clientX < 60) showUi();
|
|
if (barPosition === "right" && window.innerWidth - e.clientX < 60) showUi();
|
|
}
|
|
}}
|
|
>
|
|
<ReaderControls
|
|
{displayChapter} {adjacent} {visibleChunkLastPage}
|
|
{zoom} {zoomPct}
|
|
isFullscreen={readerState.isFullscreen}
|
|
{isBookmarked} {hasMarkerOnPage} {currentPageMarkers}
|
|
uiVisible={readerState.uiVisible}
|
|
{barPosition}
|
|
progressBar={isVerticalBar ? progressBarSnippet : undefined}
|
|
onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)}
|
|
onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)}
|
|
onMaybeMarkRead={maybeMarkCurrentRead}
|
|
onToggleBookmark={() => toggleBookmark(displayChapter, readerState.pageNumber)}
|
|
onCommitMarker={commitMarkerAction}
|
|
onDeleteMarker={deleteCurrentMarker}
|
|
onClampZoom={clampZoom}
|
|
onApplySettings={applySettings}
|
|
onDlOpen={() => readerState.dlOpen = true}
|
|
onSettingsOpen={() => { settingsState.settingsOpen = true; }}
|
|
{perMangaEnabled}
|
|
/>
|
|
|
|
{#if readerState.presetOpen}
|
|
<ReaderPresetPanel
|
|
{fit} {style} {rtl} {zoom} {zoomPct}
|
|
{perMangaEnabled}
|
|
{barPosition}
|
|
onBarPositionChange={handleBarPositionChange}
|
|
onTogglePerManga={handleTogglePerManga}
|
|
onApplySettings={applySettings}
|
|
onSavePreset={handleSavePreset}
|
|
onApplyPreset={handleApplyPreset}
|
|
onUpdatePreset={(id, patch) => readerState.updateReaderPreset(id, patch)}
|
|
onDeletePreset={(id) => readerState.deleteReaderPreset(id)}
|
|
onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)}
|
|
onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)}
|
|
onClampZoom={clampZoom}
|
|
/>
|
|
{/if}
|
|
|
|
<ReaderOverlay
|
|
{showResumeBanner}
|
|
resumePage={readerState.resumePage}
|
|
resumeFading={readerState.resumeFading}
|
|
{adjacent}
|
|
onDismissResume={() => { readerState.resumeVisible = false; readerState.resumeFading = false; }}
|
|
/>
|
|
|
|
<PageView
|
|
bind:this={pageViewRef}
|
|
{style} {imgCls} {effectiveWidth}
|
|
loading={readerState.loading}
|
|
error={readerState.error}
|
|
pageReady={readerState.pageReady}
|
|
pageGroups={readerState.pageGroups}
|
|
{currentGroup} {stripToRender}
|
|
fadingOut={readerState.fadingOut}
|
|
{tapToToggleBar}
|
|
{pinchZoomEnabled}
|
|
{barPosition}
|
|
onGetZoom={() => zoom}
|
|
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
|
|
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
|
|
onTap={handleTap}
|
|
onWheel={handleWheel}
|
|
onToggleUi={toggleUiVisibility}
|
|
{bindContainer}
|
|
/>
|
|
|
|
{#snippet progressBarSnippet()}
|
|
<ReaderProgressBar
|
|
{style}
|
|
loading={readerState.loading}
|
|
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
|
|
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
|
|
uiVisible={readerState.uiVisible}
|
|
{barPosition}
|
|
onGoPrev={goPrev}
|
|
onGoNext={goNext}
|
|
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
|
|
/>
|
|
{/snippet}
|
|
|
|
{#if !isVerticalBar}
|
|
<ReaderProgressBar
|
|
{style}
|
|
loading={readerState.loading}
|
|
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
|
|
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
|
|
uiVisible={readerState.uiVisible}
|
|
{barPosition}
|
|
onGoPrev={goPrev}
|
|
onGoNext={goNext}
|
|
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform; }
|
|
.root.containerized { position: relative; inset: auto; flex: 1; height: 100%; z-index: 0; transform: none; will-change: auto; }
|
|
|
|
.root.overlay-bars :global(.topbar) { position: absolute; top: 0; left: 0; right: 0; z-index: 10; }
|
|
.root.overlay-bars :global(.bottombar) { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; }
|
|
.root.overlay-bars :global(.viewer) { height: 100%; }
|
|
|
|
.root.bar-left :global(.viewer) { margin-left: 40px; }
|
|
.root.bar-right :global(.viewer) { margin-right: 40px; }
|
|
|
|
.root.pinch-active :global(.viewer) { touch-action: none; }
|
|
</style> |