mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Reworked ENTIRE Project for Readability
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch } from "phosphor-svelte";
|
||||
import { store } from "@store/state.svelte";
|
||||
import { readerState } from "../store/readerState.svelte";
|
||||
import type { StripChapter } from "../lib/scrollHandler";
|
||||
|
||||
interface Props {
|
||||
style: string;
|
||||
imgCls: string;
|
||||
effectiveWidth: number | undefined;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
pageReady: boolean;
|
||||
pageGroups: number[][];
|
||||
currentGroup: number[];
|
||||
stripToRender: StripChapter[];
|
||||
fadingOut: boolean;
|
||||
tapToToggleBar: boolean;
|
||||
resolveUrl: (url: string, priority?: number) => Promise<string>;
|
||||
onTap: (e: MouseEvent) => void;
|
||||
onWheel: (e: WheelEvent) => void;
|
||||
onToggleUi: () => void;
|
||||
bindContainer: (el: HTMLDivElement) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
style, imgCls, effectiveWidth, loading, error, pageReady,
|
||||
pageGroups, currentGroup, stripToRender, fadingOut,
|
||||
tapToToggleBar, resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
|
||||
}: Props = $props();
|
||||
|
||||
const INSPECT_ZOOM_STEP = 0.15;
|
||||
const INSPECT_ZOOM_MAX = 8;
|
||||
|
||||
let containerEl: HTMLDivElement;
|
||||
|
||||
function getInspectImageEl(): HTMLElement | null {
|
||||
if (!containerEl) return null;
|
||||
return (
|
||||
containerEl.querySelector<HTMLElement>(".inspect-wrap .double-wrap") ??
|
||||
containerEl.querySelector<HTMLElement>(".inspect-wrap img")
|
||||
);
|
||||
}
|
||||
|
||||
function clampInspectPan(scale: number, px: number, py: number): [number, number] {
|
||||
const img = getInspectImageEl();
|
||||
if (!img) return [px, py];
|
||||
const maxX = Math.max(0, (img.offsetWidth * (scale - 1)) / 2);
|
||||
const maxY = Math.max(0, (img.offsetHeight * (scale - 1)) / 2);
|
||||
return [Math.max(-maxX, Math.min(maxX, px)), Math.max(-maxY, Math.min(maxY, py))];
|
||||
}
|
||||
|
||||
let inspectDragging = false;
|
||||
let inspectDragMoved = false;
|
||||
let inspectDragStartX = 0;
|
||||
let inspectDragStartY = 0;
|
||||
let inspectPanStartX = 0;
|
||||
let inspectPanStartY = 0;
|
||||
|
||||
export function onInspectMouseDown(e: MouseEvent) {
|
||||
if (style === "longstrip" || readerState.inspectScale <= 1) return;
|
||||
inspectDragging = true;
|
||||
inspectDragMoved = false;
|
||||
inspectDragStartX = e.clientX;
|
||||
inspectDragStartY = e.clientY;
|
||||
inspectPanStartX = readerState.inspectPanX;
|
||||
inspectPanStartY = readerState.inspectPanY;
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
export function onInspectMouseMove(e: MouseEvent) {
|
||||
if (!inspectDragging) return;
|
||||
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
|
||||
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
|
||||
const rawY = inspectPanStartY + (e.clientY - inspectDragStartY);
|
||||
const [cx, cy] = clampInspectPan(readerState.inspectScale, rawX, rawY);
|
||||
readerState.inspectPanX = cx;
|
||||
readerState.inspectPanY = cy;
|
||||
}
|
||||
|
||||
export function onInspectMouseUp() {
|
||||
inspectDragging = false;
|
||||
}
|
||||
|
||||
export function handleWheel(e: WheelEvent) {
|
||||
if (e.ctrlKey) { onWheel(e); return; }
|
||||
if (style === "longstrip") return;
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY < 0 ? INSPECT_ZOOM_STEP : -INSPECT_ZOOM_STEP;
|
||||
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, readerState.inspectScale + delta));
|
||||
if (next === readerState.inspectScale) return;
|
||||
if (next === 1) { readerState.inspectScale = 1; readerState.inspectPanX = 0; readerState.inspectPanY = 0; return; }
|
||||
const img = getInspectImageEl();
|
||||
const anchor = img ?? containerEl;
|
||||
const rect = anchor?.getBoundingClientRect();
|
||||
const cx = rect ? e.clientX - rect.left - rect.width / 2 : 0;
|
||||
const cy = rect ? e.clientY - rect.top - rect.height / 2 : 0;
|
||||
const ratio = next / readerState.inspectScale;
|
||||
const rawPanX = cx + (readerState.inspectPanX - cx) * ratio;
|
||||
const rawPanY = cy + (readerState.inspectPanY - cy) * ratio;
|
||||
const [clampedX, clampedY] = clampInspectPan(next, rawPanX, rawPanY);
|
||||
readerState.inspectScale = next;
|
||||
readerState.inspectPanX = clampedX;
|
||||
readerState.inspectPanY = clampedY;
|
||||
}
|
||||
|
||||
function handleTap(e: MouseEvent) {
|
||||
if (style === "longstrip") return;
|
||||
if (inspectDragMoved) { inspectDragMoved = false; return; }
|
||||
onTap(e);
|
||||
}
|
||||
|
||||
function setContainer(el: HTMLDivElement) {
|
||||
containerEl = el;
|
||||
bindContainer(el);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
use:setContainer
|
||||
class="viewer"
|
||||
class:strip={style === "longstrip"}
|
||||
class:inspect-active={readerState.inspectScale > 1}
|
||||
style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""}
|
||||
role="presentation"
|
||||
tabindex="-1"
|
||||
onclick={handleTap}
|
||||
ondblclick={(e) => { if (tapToToggleBar) { const x = e.clientX / window.innerWidth; if (x >= 0.3 && x <= 0.7) onToggleUi(); } }}
|
||||
onmousedown={onInspectMouseDown}
|
||||
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
|
||||
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
|
||||
>
|
||||
|
||||
{#if loading}
|
||||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{/if}
|
||||
{#if error}
|
||||
<div class="center-overlay"><p class="error-msg">{error}</p></div>
|
||||
{/if}
|
||||
|
||||
{#if style === "longstrip"}
|
||||
{#each stripToRender as chunk}
|
||||
{#each chunk.urls as url, i}
|
||||
{#await resolveUrl(url, chunk.urls.length - i)}
|
||||
<img src="" alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" />
|
||||
{:then src}
|
||||
<img {src} alt="{chunk.chapterName} – Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" />
|
||||
{/await}
|
||||
{/each}
|
||||
{/each}
|
||||
<div style="height:1px;flex-shrink:0"></div>
|
||||
|
||||
{:else if style === "fade" && pageReady}
|
||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
|
||||
{:then src}
|
||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
{:else if style === "double" && pageReady}
|
||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||
{#if pageGroups.length}
|
||||
<div class="double-wrap">
|
||||
{#each currentGroup as pg, i}
|
||||
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
|
||||
<img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
|
||||
{:then src}
|
||||
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
|
||||
{/await}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if pageReady}
|
||||
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
|
||||
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
|
||||
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
||||
{:then src}
|
||||
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
|
||||
{/await}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
|
||||
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
|
||||
.viewer:focus { outline: none; }
|
||||
.viewer.inspect-active { cursor: grab; overflow: hidden; }
|
||||
.viewer.inspect-active:active { cursor: grabbing; }
|
||||
|
||||
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
|
||||
|
||||
.img { display: block; user-select: none; image-rendering: auto; }
|
||||
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
|
||||
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
|
||||
:global(.fit-height) { max-height: calc(100vh - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
|
||||
:global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
|
||||
:global(.fit-original) { max-width: 100%; width: auto; height: auto; }
|
||||
:global(.strip-gap) { margin-bottom: 8px; }
|
||||
|
||||
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--effective-width, 100%) * 2); width: 100%; }
|
||||
.page-half { flex: 1; min-width: 0; object-fit: contain; }
|
||||
.gap-left { margin-right: 2px; }
|
||||
.gap-right { margin-left: 2px; }
|
||||
|
||||
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
||||
</style>
|
||||
@@ -0,0 +1,513 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack, tick } from "svelte";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { gql } from "@api/client";
|
||||
import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
|
||||
import { store, updateSettings, openReader, closeReader, addHistory,
|
||||
addBookmark, removeBookmark, addMarker, updateMarker, removeMarker,
|
||||
setSettingsOpen } from "@store/state.svelte";
|
||||
import { setReading } from "@store/discord";
|
||||
import { DEFAULT_KEYBINDS } from "@core/keybinds/defaultBinds";
|
||||
import { readerState, PAGE_STYLES } from "../store/readerState.svelte";
|
||||
import { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups } from "../lib/pageLoader";
|
||||
import { setupScrollTracking, appendNextChapter } from "../lib/scrollHandler";
|
||||
import { createReaderKeyHandler } from "../lib/readerKeybinds";
|
||||
import { markChapterRead, getMangaPrefs, toggleBookmark } from "../lib/chapterActions";
|
||||
import { goForward, goBack, jumpToPage } from "../lib/navigation";
|
||||
import { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "../lib/zoomHelpers";
|
||||
import { loadChapter, scheduleResumeDismiss } from "../lib/chapterLoader";
|
||||
import type { FitMode } from "@store/state.svelte";
|
||||
import ReaderControls from "./ReaderControls.svelte";
|
||||
import PageView from "./PageView.svelte";
|
||||
import ReaderProgressBar from "./ReaderProgressBar.svelte";
|
||||
import ReaderOverlay from "./ReaderOverlay.svelte";
|
||||
|
||||
const win = getCurrentWindow();
|
||||
const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH");
|
||||
const rtl = $derived(store.settings.readingDirection === "rtl");
|
||||
const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
|
||||
const style = $derived((store.settings.pageStyle ?? "single") as typeof PAGE_STYLES[number]);
|
||||
const zoom = $derived(store.settings.readerZoom ?? 1.0);
|
||||
const autoNext = $derived(store.settings.autoNextChapter ?? false);
|
||||
const markOnNext = $derived(store.settings.markReadOnNext ?? true);
|
||||
const overlayBars = $derived(store.settings.overlayBars ?? false);
|
||||
const tapToToggleBar = $derived(store.settings.tapToToggleBar ?? false);
|
||||
const lastPage = $derived(store.pageUrls.length);
|
||||
const effectiveWidth = $derived(readerState.containerWidth > 0 ? Math.round(readerState.containerWidth * zoom) : undefined);
|
||||
const zoomPct = $derived(Math.round(zoom * 100));
|
||||
|
||||
const displayChapter = $derived(
|
||||
style === "longstrip" && readerState.visibleChapterId
|
||||
? (store.activeChapterList.find(c => c.id === readerState.visibleChapterId) ?? store.activeChapter)
|
||||
: store.activeChapter
|
||||
);
|
||||
|
||||
const currentBookmark = $derived(
|
||||
store.activeManga ? store.bookmarks.find(b => b.mangaId === store.activeManga!.id) : undefined
|
||||
);
|
||||
const isBookmarked = $derived(
|
||||
!!currentBookmark &&
|
||||
currentBookmark.chapterId === displayChapter?.id &&
|
||||
currentBookmark.pageNumber === store.pageNumber
|
||||
);
|
||||
|
||||
const currentPageMarkers = $derived(displayChapter ? store.getMarkersForPage(displayChapter.id, store.pageNumber) : []);
|
||||
const activeChapterMarkers = $derived(displayChapter ? store.getMarkersForChapter(displayChapter.id) : []);
|
||||
const hasMarkerOnPage = $derived(currentPageMarkers.length > 0);
|
||||
|
||||
const showResumeBanner = $derived(
|
||||
readerState.resumeVisible && readerState.resumePage > 1 &&
|
||||
(style === "longstrip" ? readerState.stripResumeReady : store.pageNumber === readerState.resumePage)
|
||||
);
|
||||
|
||||
const adjacent = $derived.by(() => {
|
||||
const ref = displayChapter ?? store.activeChapter;
|
||||
if (!ref || !store.activeChapterList.length) return { prev: null, next: null, remaining: [] };
|
||||
const idx = store.activeChapterList.findIndex(c => c.id === ref.id);
|
||||
return {
|
||||
prev: idx > 0 ? store.activeChapterList[idx - 1] : null,
|
||||
next: idx < store.activeChapterList.length - 1 ? store.activeChapterList[idx + 1] : null,
|
||||
remaining: store.activeChapterList.slice(idx + 1),
|
||||
};
|
||||
});
|
||||
|
||||
const visibleChunkLastPage = $derived.by(() => {
|
||||
if (style !== "longstrip") return lastPage;
|
||||
const chId = readerState.visibleChapterId ?? store.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",
|
||||
store.settings.optimizeContrast && "optimize-contrast",
|
||||
].filter(Boolean).join(" "));
|
||||
|
||||
const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]);
|
||||
|
||||
const stripToRender = $derived(
|
||||
style === "longstrip"
|
||||
? (readerState.stripChapters.length > 0
|
||||
? readerState.stripChapters
|
||||
: [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls }])
|
||||
: []
|
||||
);
|
||||
|
||||
const currentGroup = $derived.by(() => {
|
||||
const group = style === "double" && readerState.pageGroups.length
|
||||
? (readerState.pageGroups.find(g => g.includes(store.pageNumber)) ?? [store.pageNumber])
|
||||
: [store.pageNumber];
|
||||
return rtl ? [...group].reverse() : group;
|
||||
});
|
||||
|
||||
const sliderPage = $derived.by(() => {
|
||||
if (style === "double" && readerState.pageGroups.length)
|
||||
return readerState.pageGroups.findIndex(g => g.includes(store.pageNumber)) + 1;
|
||||
return store.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);
|
||||
|
||||
let containerEl: HTMLDivElement | null = null;
|
||||
let pageViewRef: PageView;
|
||||
let zoomAnchor = { el: null as HTMLElement | null, offset: 0 };
|
||||
let hideTimer: 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 ?? store.activeChapter;
|
||||
if (ch && markOnNext) markChapterRead(ch.id, markedRead);
|
||||
}
|
||||
|
||||
function commitMarker() {
|
||||
const ch = displayChapter;
|
||||
const manga = store.activeManga;
|
||||
if (!ch || !manga) return;
|
||||
if (readerState.markerEditId) {
|
||||
updateMarker(readerState.markerEditId, { note: readerState.markerNote.trim(), color: readerState.markerColor });
|
||||
} else {
|
||||
addMarker({ mangaId: manga.id, mangaTitle: manga.title, thumbnailUrl: manga.thumbnailUrl, chapterId: ch.id, chapterName: ch.name, pageNumber: store.pageNumber, note: readerState.markerNote.trim(), color: readerState.markerColor });
|
||||
}
|
||||
readerState.clearMarkerPopover();
|
||||
}
|
||||
|
||||
function deleteCurrentMarker() {
|
||||
if (readerState.markerEditId) 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);
|
||||
const ZOOM_STEP = 0.05;
|
||||
updateSettings({ readerZoom: clampZoom(zoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP)) });
|
||||
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,
|
||||
goToPage: (p) => jumpToPage(p, style, lastPage, containerEl),
|
||||
lastPage: () => lastPage,
|
||||
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); updateSettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
||||
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); updateSettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
||||
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); updateSettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
|
||||
toggleDirection: () => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }),
|
||||
openSettings: () => setSettingsOpen(true),
|
||||
toggleBookmark: () => toggleBookmark(displayChapter, store.pageNumber),
|
||||
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(); openReader(ch, store.activeChapterList); }
|
||||
},
|
||||
chapterPrev: () => {
|
||||
const ch = rtl ? adjacent.next : adjacent.prev;
|
||||
if (ch) openReader(ch, store.activeChapterList);
|
||||
},
|
||||
closePopovers: () => readerState.closeAllPopovers(),
|
||||
getKeybinds: () => store.settings.keybinds ?? DEFAULT_KEYBINDS,
|
||||
});
|
||||
|
||||
function bindContainer(el: HTMLDivElement) { containerEl = el; }
|
||||
|
||||
$effect(() => {
|
||||
const chapter = displayChapter;
|
||||
const manga = store.activeManga;
|
||||
if (store.settings.discordRpc && chapter && manga) setReading(manga, chapter);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const ch = store.activeChapter;
|
||||
if (ch) untrack(() => loadChapter(ch.id, useBlob, abortCtrl, startAtLastPageRef, markedRead, adjacent));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
|
||||
const ch = store.activeChapter;
|
||||
const urls = store.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 === store.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();
|
||||
if (prefs.downloadAhead > 0) {
|
||||
const list = store.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.isDownloaded && !c.isRead).map(c => c.id);
|
||||
if (toQueue.length) gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: toQueue }).catch(console.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const bookmark = store.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) => { store.pageNumber = p; },
|
||||
onChapterChange: (id) => { readerState.visibleChapterId = id; },
|
||||
onMarkRead: (id) => markChapterRead(id, markedRead),
|
||||
onAppend: () => {
|
||||
if (appending || !readerState.stripChapters.length) return;
|
||||
appending = true;
|
||||
appendNextChapter(
|
||||
stripChaptersRef,
|
||||
store.activeChapterList,
|
||||
(id) => fetchPages(id, useBlob),
|
||||
(url) => preloadImage(url, useBlob),
|
||||
(next) => { readerState.stripChapters = [...readerState.stripChapters, next]; appending = false; },
|
||||
() => { appending = false; },
|
||||
);
|
||||
},
|
||||
getStripChapters: () => stripChaptersRef,
|
||||
getPageUrls: () => store.pageUrls,
|
||||
shouldAutoMark: () => store.settings.autoMarkRead ?? true,
|
||||
}); });
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (store.activeChapter && store.activeChapterList.length) {
|
||||
const idx = store.activeChapterList.findIndex(c => c.id === store.activeChapter!.id);
|
||||
if (idx >= 0) {
|
||||
const next = store.activeChapterList[idx + 1];
|
||||
const prev = store.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" && store.pageUrls.length) {
|
||||
let cancelled = false;
|
||||
const snap = store.pageUrls;
|
||||
Promise.all(snap.map(url => measureAspect(url, useBlob))).then(aspects => {
|
||||
if (cancelled || snap !== store.pageUrls) return;
|
||||
readerState.pageGroups = buildPageGroups(snap, aspects, store.settings.offsetDoubleSpreads ?? false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
} else { readerState.pageGroups = []; }
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const ahead = store.settings.preloadPages ?? 3;
|
||||
const current = store.pageUrls[store.pageNumber - 1];
|
||||
if (!current) return;
|
||||
if (useBlob) {
|
||||
import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => {
|
||||
getBlobUrl(current, 999);
|
||||
const upcoming = Array.from({ length: ahead }, (_, i) => store.pageUrls[store.pageNumber + i]).filter(Boolean) as string[];
|
||||
const behind = store.pageUrls[store.pageNumber - 2];
|
||||
preloadBlobUrls(upcoming, ahead);
|
||||
if (behind) preloadBlobUrls([behind], 0);
|
||||
});
|
||||
} else {
|
||||
for (let i = 1; i <= ahead; i++) {
|
||||
const url = store.pageUrls[store.pageNumber - 1 + i];
|
||||
if (url) preloadImage(url, useBlob);
|
||||
}
|
||||
const behind = store.pageUrls[store.pageNumber - 2];
|
||||
if (behind) preloadImage(behind, useBlob);
|
||||
}
|
||||
});
|
||||
|
||||
$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 ?? store.activeChapter;
|
||||
if (ch && lastPage && store.activeManga) {
|
||||
const { id: chapterId, name: chapterName } = ch;
|
||||
const { id: mangaId, title: mangaTitle, thumbnailUrl: thumb } = store.activeManga;
|
||||
const pageNum = store.pageNumber;
|
||||
const atLast = pageNum === lastPage;
|
||||
if (pageNum > 1) hasNavigated = true;
|
||||
untrack(() => {
|
||||
if (!hasNavigated) return;
|
||||
if (style === "longstrip" && readerState.visibleChapterId && chapterId !== readerState.visibleChapterId) return;
|
||||
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, readAt: Date.now() });
|
||||
if (store.settings.autoBookmark ?? true) {
|
||||
const existing = store.bookmarks.find(b => b.mangaId === mangaId && b.chapterId !== chapterId);
|
||||
if (existing) removeBookmark(existing.chapterId);
|
||||
addBookmark({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum });
|
||||
}
|
||||
if (style !== "longstrip" && (store.settings.autoMarkRead ?? true) && atLast) markChapterRead(chapterId, markedRead);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
showUi();
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("mousemove", pageViewRef.onInspectMouseMove);
|
||||
window.addEventListener("mouseup", pageViewRef.onInspectMouseUp);
|
||||
|
||||
readerState.isFullscreen = await win.isFullscreen();
|
||||
const unlistenFs = await win.onResized(async () => {
|
||||
readerState.isFullscreen = await win.isFullscreen();
|
||||
});
|
||||
|
||||
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);
|
||||
cleanupScroll();
|
||||
unlistenFs();
|
||||
ro.disconnect();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="root"
|
||||
class:overlay-bars={overlayBars}
|
||||
role="presentation"
|
||||
onmousemove={(e) => { if (!tapToToggleBar && (e.clientY < 60 || window.innerHeight - e.clientY < 60)) showUi(); }}
|
||||
>
|
||||
<ReaderControls
|
||||
{displayChapter} {adjacent} {visibleChunkLastPage}
|
||||
{fit} {fitLabel} {style} {rtl} {zoom} {zoomPct}
|
||||
isFullscreen={readerState.isFullscreen}
|
||||
{isBookmarked} {hasMarkerOnPage} {currentPageMarkers}
|
||||
{autoNext} {markOnNext}
|
||||
uiVisible={readerState.uiVisible}
|
||||
{hideTimer}
|
||||
onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)}
|
||||
onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)}
|
||||
onMaybeMarkRead={maybeMarkCurrentRead}
|
||||
onToggleBookmark={() => toggleBookmark(displayChapter, store.pageNumber)}
|
||||
onCommitMarker={commitMarker}
|
||||
onDeleteMarker={deleteCurrentMarker}
|
||||
onClampZoom={clampZoom}
|
||||
onDlOpen={() => readerState.dlOpen = true}
|
||||
{win}
|
||||
/>
|
||||
|
||||
<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}
|
||||
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
|
||||
onTap={handleTap}
|
||||
onWheel={handleWheel}
|
||||
onToggleUi={toggleUiVisibility}
|
||||
{bindContainer}
|
||||
/>
|
||||
|
||||
<ReaderProgressBar
|
||||
{style}
|
||||
loading={readerState.loading}
|
||||
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
|
||||
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
|
||||
uiVisible={readerState.uiVisible}
|
||||
onGoPrev={goPrev}
|
||||
onGoNext={goNext}
|
||||
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
|
||||
/>
|
||||
</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.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%; }
|
||||
</style>
|
||||
@@ -0,0 +1,373 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
X, CaretLeft, CaretRight,
|
||||
Square, Rows, BookOpen, MonitorPlay,
|
||||
ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical,
|
||||
MagnifyingGlassMinus, MagnifyingGlassPlus,
|
||||
Bookmark, MapPin, Download, Check,
|
||||
} from "phosphor-svelte";
|
||||
import { store, updateSettings } from "@store/state.svelte";
|
||||
import { openReader, closeReader } from "@store/state.svelte";
|
||||
import { readerState, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX, PAGE_STYLES } from "../store/readerState.svelte";
|
||||
import type { FitMode } from "@store/state.svelte";
|
||||
import type { Chapter } from "@types";
|
||||
|
||||
interface Props {
|
||||
displayChapter: Chapter | null;
|
||||
adjacent: { prev: Chapter | null; next: Chapter | null };
|
||||
visibleChunkLastPage: number;
|
||||
fit: FitMode;
|
||||
fitLabel: string;
|
||||
style: string;
|
||||
rtl: boolean;
|
||||
zoom: number;
|
||||
zoomPct: number;
|
||||
isFullscreen: boolean;
|
||||
isBookmarked: boolean;
|
||||
hasMarkerOnPage: boolean;
|
||||
currentPageMarkers: { id: string; color: import("@store/state.svelte").MarkerColor; note: string }[];
|
||||
autoNext: boolean;
|
||||
markOnNext: boolean;
|
||||
uiVisible: boolean;
|
||||
hideTimer: ReturnType<typeof setTimeout> | null;
|
||||
onCaptureZoomAnchor: () => void;
|
||||
onRestoreZoomAnchor: () => void;
|
||||
onMaybeMarkRead: () => void;
|
||||
onToggleBookmark: () => void;
|
||||
onCommitMarker: () => void;
|
||||
onDeleteMarker: () => void;
|
||||
onClampZoom: (z: number) => number;
|
||||
onDlOpen: () => void;
|
||||
win: import("@tauri-apps/api/window").Window;
|
||||
}
|
||||
|
||||
const {
|
||||
displayChapter, adjacent, visibleChunkLastPage,
|
||||
fit, fitLabel, style, rtl, zoom, zoomPct,
|
||||
isFullscreen, isBookmarked, hasMarkerOnPage, currentPageMarkers,
|
||||
autoNext, markOnNext, uiVisible, hideTimer,
|
||||
onCaptureZoomAnchor, onRestoreZoomAnchor,
|
||||
onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker,
|
||||
onClampZoom, onDlOpen, win,
|
||||
}: Props = $props();
|
||||
|
||||
function adjustZoom(delta: number) {
|
||||
onCaptureZoomAnchor();
|
||||
updateSettings({ readerZoom: onClampZoom(zoom + delta) });
|
||||
onRestoreZoomAnchor();
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
onCaptureZoomAnchor();
|
||||
updateSettings({ readerZoom: 1.0 });
|
||||
onRestoreZoomAnchor();
|
||||
}
|
||||
|
||||
function cycleStyle() {
|
||||
const idx = PAGE_STYLES.indexOf(style as typeof PAGE_STYLES[number]);
|
||||
updateSettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] });
|
||||
}
|
||||
|
||||
function cycleFit() {
|
||||
const opts: FitMode[] = ["width", "height", "screen", "original"];
|
||||
updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] });
|
||||
}
|
||||
|
||||
function keepUiAlive() {
|
||||
readerState.uiVisible = true;
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
}
|
||||
|
||||
function openMarkerPopover() {
|
||||
if (currentPageMarkers.length > 0) {
|
||||
const first = currentPageMarkers[0];
|
||||
readerState.openMarker(first.id, first.note, first.color);
|
||||
} else {
|
||||
readerState.openMarker("", "", "yellow");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="topbar" class:hidden={!uiVisible}>
|
||||
|
||||
<div class="topbar-left">
|
||||
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
|
||||
<button class="icon-btn"
|
||||
onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); openReader(adjacent.prev, store.activeChapterList); } }}
|
||||
disabled={!adjacent.prev}>
|
||||
<CaretLeft size={14} weight="light" />
|
||||
</button>
|
||||
<span class="ch-label">
|
||||
<span class="ch-title">{store.activeManga?.title}</span>
|
||||
<span class="ch-sep">/</span>
|
||||
<span>{displayChapter?.name}</span>
|
||||
</span>
|
||||
<button class="icon-btn"
|
||||
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, store.activeChapterList); } }}
|
||||
disabled={!adjacent.next}>
|
||||
<CaretRight size={14} weight="light" />
|
||||
</button>
|
||||
<span class="page-label">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<div class="top-sep"></div>
|
||||
|
||||
<button class="mode-btn" onclick={cycleFit}>
|
||||
{#if fit === "width"}<ArrowsLeftRight size={14} weight="light" />
|
||||
{:else if fit === "height"}<ArrowsVertical size={14} weight="light" />
|
||||
{:else if fit === "screen"}<ArrowsIn size={14} weight="light" />
|
||||
{:else}<ArrowsOut size={14} weight="light" />{/if}
|
||||
<span class="mode-label">{fitLabel}</span>
|
||||
</button>
|
||||
|
||||
<div class="zoom-wrap">
|
||||
<div class="zoom-inline">
|
||||
<button class="zoom-step-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
|
||||
<MagnifyingGlassMinus size={13} weight="light" />
|
||||
</button>
|
||||
<button class="zoom-pct-btn" onclick={() => readerState.zoomOpen = !readerState.zoomOpen} title="Click to adjust zoom">
|
||||
{zoomPct}%
|
||||
</button>
|
||||
<button class="zoom-step-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
|
||||
<MagnifyingGlassPlus size={13} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
{#if readerState.zoomOpen}
|
||||
<div class="zoom-popover">
|
||||
<div class="zoom-slider-row">
|
||||
<input type="range" class="zoom-slider" min={10} max={100} step={5} value={zoomPct}
|
||||
oninput={(e) => { onCaptureZoomAnchor(); updateSettings({ readerZoom: onClampZoom(Number(e.currentTarget.value) / 100) }); onRestoreZoomAnchor(); }} />
|
||||
</div>
|
||||
<button class="zoom-reset" onclick={resetZoom} disabled={zoom === 1.0}>Reset</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="mode-btn" class:active={rtl} onclick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}>
|
||||
<ArrowsLeftRight size={14} weight="light" /><span class="mode-label">{rtl ? "RTL" : "LTR"}</span>
|
||||
</button>
|
||||
|
||||
<button class="mode-btn" onclick={cycleStyle} title="Cycle view mode">
|
||||
{#if style === "single"}<Square size={14} weight="light" />
|
||||
{:else if style === "fade"}<MonitorPlay size={14} weight="light" />
|
||||
{:else if style === "double"}<BookOpen size={14} weight="light" />
|
||||
{:else}<Rows size={14} weight="light" />{/if}
|
||||
<span class="mode-label">{style}</span>
|
||||
</button>
|
||||
|
||||
<div class="mode-extras">
|
||||
{#if style === "double"}
|
||||
<button class="mode-btn" class:active={store.settings.offsetDoubleSpreads}
|
||||
onclick={() => updateSettings({ offsetDoubleSpreads: !store.settings.offsetDoubleSpreads })}>
|
||||
<span class="mode-label">Offset</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if style === "longstrip"}
|
||||
<button class="mode-btn" class:active={store.settings.pageGap}
|
||||
onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}>
|
||||
<span class="mode-label">Gap</span>
|
||||
</button>
|
||||
<button class="mode-btn" class:active={autoNext}
|
||||
onclick={() => updateSettings({ autoNextChapter: !autoNext })}>
|
||||
<span class="mode-label">Auto</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if !autoNext}
|
||||
<button class="mode-btn" class:active={markOnNext}
|
||||
onclick={() => updateSettings({ markReadOnNext: !markOnNext })}>
|
||||
<span class="mode-label">Mk.Read</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="mode-btn" onclick={onDlOpen}>
|
||||
<Download size={14} weight="light" />
|
||||
</button>
|
||||
|
||||
<div class="marker-wrap">
|
||||
<button
|
||||
class="icon-btn"
|
||||
class:active={hasMarkerOnPage}
|
||||
class:marker-btn-has={hasMarkerOnPage}
|
||||
onclick={openMarkerPopover}
|
||||
title={hasMarkerOnPage ? "Edit marker" : "Add marker"}
|
||||
style={hasMarkerOnPage ? `--marker-color:${MARKER_COLOR_HEX[currentPageMarkers[0].color]}` : ""}
|
||||
>
|
||||
<MapPin size={14} weight={hasMarkerOnPage ? "fill" : "regular"} />
|
||||
</button>
|
||||
|
||||
{#if readerState.markerOpen}
|
||||
<div class="marker-popover" role="presentation"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onmouseenter={keepUiAlive}
|
||||
>
|
||||
<div class="marker-pop-header">
|
||||
<span class="marker-pop-title">
|
||||
{readerState.markerEditId ? "Edit marker" : "New marker"} · p.{store.pageNumber}
|
||||
</span>
|
||||
{#if readerState.markerEditId}
|
||||
<button class="marker-delete-btn" onclick={onDeleteMarker} title="Delete marker">
|
||||
<X size={12} weight="light" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="marker-color-row">
|
||||
{#each MARKER_COLORS as c}
|
||||
<button
|
||||
class="marker-swatch"
|
||||
class:marker-swatch-active={readerState.markerColor === c}
|
||||
style="--swatch:{MARKER_COLOR_HEX[c]}"
|
||||
onclick={() => readerState.markerColor = c}
|
||||
title={c}
|
||||
>
|
||||
<span class="swatch-dot"></span>
|
||||
<span class="swatch-label">{c}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<textarea
|
||||
class="marker-textarea"
|
||||
style="--accent-marker:{MARKER_COLOR_HEX[readerState.markerColor]}"
|
||||
rows={3}
|
||||
placeholder="Note (optional)…"
|
||||
bind:value={readerState.markerNote}
|
||||
onmouseenter={keepUiAlive}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onCommitMarker(); }
|
||||
if (e.key === "Escape") readerState.markerOpen = false;
|
||||
}}
|
||||
></textarea>
|
||||
<div class="marker-pop-actions">
|
||||
<button class="marker-save-btn" style="--accent-marker:{MARKER_COLOR_HEX[readerState.markerColor]}" onclick={onCommitMarker}>
|
||||
<Check size={12} weight="bold" />
|
||||
{readerState.markerEditId ? "Update" : "Save"}
|
||||
</button>
|
||||
<button class="marker-cancel-btn" onclick={() => readerState.markerOpen = false}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="icon-btn" class:active={isBookmarked} onclick={onToggleBookmark}
|
||||
title={isBookmarked ? "Remove bookmark" : "Bookmark this page"}>
|
||||
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
|
||||
</button>
|
||||
|
||||
<div class="wc-wrap">
|
||||
<button
|
||||
class="icon-btn"
|
||||
class:active={readerState.winOpen}
|
||||
onclick={() => { readerState.winOpen = !readerState.winOpen; readerState.markerOpen = false; readerState.zoomOpen = false; readerState.dlOpen = false; }}
|
||||
title="Window controls"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<circle cx="6" cy="1.5" r="1.2" fill="currentColor"/>
|
||||
<circle cx="6" cy="6" r="1.2" fill="currentColor"/>
|
||||
<circle cx="6" cy="10.5" r="1.2" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if readerState.winOpen}
|
||||
<div class="wc-dropdown" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||
<button class="wc-btn" onclick={() => { readerState.winOpen = false; win.minimize(); }}>
|
||||
<svg width="10" height="1" viewBox="0 0 10 1"><line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
<span>Minimize</span>
|
||||
</button>
|
||||
<button class="wc-btn" onclick={() => { readerState.winOpen = false; win.toggleMaximize(); }}>
|
||||
{#if isFullscreen}
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="6,1 9,1 9,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="9,6 9,9 6,9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="9" height="9" viewBox="0 0 9 9"><rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
|
||||
{/if}
|
||||
<span>{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}</span>
|
||||
</button>
|
||||
<button class="wc-btn wc-close" onclick={() => { readerState.winOpen = false; win.close(); }}>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>Close</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.topbar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-1); padding: 0 var(--sp-3); height: 40px; background: var(--bg-void); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; position: relative; z-index: 2; transition: opacity 0.25s ease; }
|
||||
.topbar.hidden { opacity: 0; pointer-events: none; }
|
||||
|
||||
.topbar-left { display: flex; align-items: center; gap: var(--sp-1); min-width: 0; flex: 1; overflow: hidden; }
|
||||
.topbar-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.mode-extras { display: flex; align-items: center; gap: var(--sp-1); min-width: 0; }
|
||||
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.icon-btn:disabled { opacity: 0.2; cursor: default; }
|
||||
.icon-btn.active { color: var(--accent-fg); }
|
||||
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; }
|
||||
|
||||
.ch-label { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
|
||||
.page-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
.top-sep { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); }
|
||||
|
||||
.mode-btn { display: flex; align-items: center; gap: 4px; padding: 4px var(--sp-2); border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); transition: color var(--t-base), background var(--t-base); }
|
||||
.mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.mode-label { text-transform: capitalize; }
|
||||
|
||||
.zoom-wrap { position: relative; flex-shrink: 0; }
|
||||
.zoom-inline { display: flex; align-items: center; gap: 1px; background: var(--bg-overlay); border: 1px solid var(--border-base); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.zoom-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 24px; color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
||||
.zoom-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.zoom-step-btn:disabled { opacity: 0.25; cursor: default; }
|
||||
.zoom-pct-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); padding: 0 var(--sp-2); height: 24px; min-width: 38px; text-align: center; transition: color var(--t-base), background var(--t-base); border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); }
|
||||
.zoom-pct-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; translate: -50% 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 180px; animation: scaleIn 0.1s ease both; transform-origin: top center; }
|
||||
.zoom-slider-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.zoom-slider { flex: 1; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
|
||||
.zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
|
||||
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 3px var(--sp-2); border-radius: var(--radius-sm); border: 1px solid var(--border-dim); transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
|
||||
.zoom-reset:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||
.zoom-reset:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.marker-wrap { position: relative; flex-shrink: 0; }
|
||||
.marker-popover { position: absolute; top: calc(100% + 8px); right: 0; width: 240px; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 12px 32px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4); z-index: 100; animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||
.marker-pop-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.marker-pop-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.marker-delete-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast); }
|
||||
.marker-delete-btn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
.marker-color-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.marker-swatch { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 4px; border-radius: var(--radius-sm); background: none; border: none; cursor: pointer; flex: 1; transition: background var(--t-fast); }
|
||||
.marker-swatch:hover { background: var(--bg-overlay); }
|
||||
.swatch-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--swatch); box-shadow: 0 0 0 0 var(--swatch); transition: box-shadow var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
|
||||
.marker-swatch:hover .swatch-dot { transform: scale(1.15); }
|
||||
.marker-swatch-active .swatch-dot { box-shadow: 0 0 0 3px color-mix(in srgb, var(--swatch) 30%, transparent); transform: scale(1.1); }
|
||||
.swatch-label { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); color: var(--text-faint); text-transform: capitalize; line-height: 1; }
|
||||
.marker-swatch-active .swatch-label { color: var(--text-muted); }
|
||||
.marker-textarea { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 7px 9px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; resize: none; font-family: inherit; line-height: var(--leading-snug); transition: border-color var(--t-base), box-shadow var(--t-base); }
|
||||
.marker-textarea:focus { border-color: var(--accent-marker, var(--border-focus)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-marker, var(--accent)) 18%, transparent); }
|
||||
.marker-pop-actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.marker-save-btn { display: flex; align-items: center; gap: 5px; padding: 6px 14px; border-radius: var(--radius-sm); border: 1px solid color-mix(in srgb, var(--accent-marker, var(--accent)) 50%, transparent); background: color-mix(in srgb, var(--accent-marker, var(--accent)) 15%, transparent); color: var(--accent-marker, var(--accent-fg)); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: filter var(--t-fast); }
|
||||
.marker-save-btn:hover { filter: brightness(1.2); }
|
||||
.marker-cancel-btn { flex: 1; padding: 6px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); text-align: center; }
|
||||
.marker-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
|
||||
.wc-wrap { position: relative; flex-shrink: 0; }
|
||||
.wc-dropdown { position: absolute; top: calc(100% + 6px); right: 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); display: flex; flex-direction: column; gap: 2px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 148px; animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||
.wc-btn { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-2); border-radius: var(--radius-sm); background: none; border: none; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; width: 100%; transition: color var(--t-base), background var(--t-base); }
|
||||
.wc-btn svg { flex-shrink: 0; opacity: 0.75; }
|
||||
.wc-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
|
||||
.wc-close:hover { color: #fff; background: #c0392b; }
|
||||
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { gql } from "@api/client";
|
||||
import { store } from "@store/state.svelte";
|
||||
import { readerState } from "../store/readerState.svelte";
|
||||
import { ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
|
||||
import type { Chapter } from "@types";
|
||||
|
||||
interface Props {
|
||||
showResumeBanner: boolean;
|
||||
resumePage: number;
|
||||
resumeFading: boolean;
|
||||
adjacent: { remaining: Chapter[] };
|
||||
onDismissResume: () => void;
|
||||
}
|
||||
|
||||
const { showResumeBanner, resumePage, resumeFading, adjacent, onDismissResume }: Props = $props();
|
||||
|
||||
async function runDl(fn: () => Promise<unknown>) {
|
||||
readerState.dlBusy = true;
|
||||
try { await fn(); } catch (e) { console.error(e); }
|
||||
readerState.dlBusy = false;
|
||||
readerState.dlOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showResumeBanner}
|
||||
<button class="resume-banner" class:fading={resumeFading} onclick={onDismissResume}>
|
||||
<span>Bookmark at page {resumePage}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if readerState.dlOpen && store.activeChapter}
|
||||
{@const queueable = adjacent.remaining.filter(c => !c.isDownloaded)}
|
||||
<div class="dl-backdrop" role="presentation" onclick={() => readerState.dlOpen = false}>
|
||||
<div class="dl-modal" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||
<p class="dl-title">Download</p>
|
||||
<button class="dl-option" disabled={readerState.dlBusy || !!store.activeChapter.isDownloaded}
|
||||
onclick={() => runDl(() => gql(ENQUEUE_DOWNLOAD, { chapterId: store.activeChapter!.id }))}>
|
||||
This chapter
|
||||
<span class="dl-sub">{store.activeChapter.isDownloaded ? "Already downloaded" : store.activeChapter.name}</span>
|
||||
</button>
|
||||
<div class="dl-row">
|
||||
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
||||
onclick={() => runDl(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.slice(0, readerState.nextN).map(c => c.id) }))}>
|
||||
Next chapters
|
||||
<span class="dl-sub">{Math.min(readerState.nextN, queueable.length)} not yet downloaded</span>
|
||||
</button>
|
||||
<div class="dl-stepper" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||
<button class="dl-step-btn" onclick={() => readerState.nextN = Math.max(1, readerState.nextN - 1)} disabled={readerState.nextN <= 1}>−</button>
|
||||
<span class="dl-step-val">{readerState.nextN}</span>
|
||||
<button class="dl-step-btn" onclick={() => readerState.nextN = Math.min(queueable.length || 1, readerState.nextN + 1)} disabled={readerState.nextN >= queueable.length}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
||||
onclick={() => runDl(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.map(c => c.id) }))}>
|
||||
All remaining
|
||||
<span class="dl-sub">{queueable.length} not yet downloaded</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.resume-banner { position: fixed; top: 48px; left: 50%; display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: 6px var(--sp-3); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); z-index: 20; box-shadow: 0 4px 16px rgba(0,0,0,0.4); animation: bannerIn 0.2s cubic-bezier(0.16,1,0.3,1) both; white-space: nowrap; cursor: pointer; text-align: left; }
|
||||
.resume-banner.fading { animation: bannerOut 1s ease forwards; }
|
||||
@keyframes bannerIn { from { opacity: 0; transform: translateX(-50%) translateY(-6px) scale(0.97); } to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } }
|
||||
@keyframes bannerOut { from { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } to { opacity: 0; transform: translateX(-50%) translateY(-4px) scale(0.97); } }
|
||||
|
||||
.dl-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-reader) + 10); display: flex; align-items: flex-start; justify-content: flex-end; padding: 48px var(--sp-4) 0; }
|
||||
.dl-modal { background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-3); min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.6); animation: scaleIn 0.12s ease both; transform-origin: top right; }
|
||||
.dl-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px var(--sp-2) var(--sp-2); border-bottom: 1px solid var(--border-dim); margin-bottom: var(--sp-1); }
|
||||
.dl-option { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
||||
.dl-option:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.dl-option:disabled { opacity: 0.3; cursor: default; }
|
||||
.dl-sub { font-size: var(--text-xs); color: var(--text-faint); }
|
||||
.dl-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.dl-stepper { display: flex; align-items: center; gap: 2px; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0; }
|
||||
.dl-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 28px; font-size: var(--text-base); color: var(--text-muted); background: none; border: none; cursor: pointer; line-height: 1; transition: color var(--t-fast), background var(--t-fast); }
|
||||
.dl-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.dl-step-btn:disabled { opacity: 0.25; cursor: default; }
|
||||
.dl-step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 24px; text-align: center; letter-spacing: var(--tracking-wide); }
|
||||
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, ArrowRight } from "phosphor-svelte";
|
||||
import { readerState, MARKER_COLOR_HEX } from "../store/readerState.svelte";
|
||||
import type { BookmarkEntry, MarkerEntry } from "@store/state.svelte";
|
||||
import type { Chapter } from "@types";
|
||||
|
||||
interface Props {
|
||||
style: string;
|
||||
loading: boolean;
|
||||
rtl: boolean;
|
||||
sliderPage: number;
|
||||
sliderMax: number;
|
||||
sliderPct: number;
|
||||
lastPage: number;
|
||||
displayChapter: Chapter | null;
|
||||
currentBookmark: BookmarkEntry | undefined;
|
||||
activeChapterMarkers: MarkerEntry[];
|
||||
adjacent: { prev: Chapter | null; next: Chapter | null };
|
||||
uiVisible: boolean;
|
||||
onGoPrev: () => void;
|
||||
onGoNext: () => void;
|
||||
onJumpToPage: (page: number) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
style, loading, rtl, sliderPage, sliderMax, sliderPct, lastPage,
|
||||
displayChapter, currentBookmark, activeChapterMarkers, adjacent, uiVisible,
|
||||
onGoPrev, onGoNext, onJumpToPage,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="bottombar" class:hidden={!uiVisible}>
|
||||
<button class="nav-btn" onclick={onGoPrev}
|
||||
disabled={loading || (style === "longstrip" ? !adjacent.prev : (sliderPage === 1 && !adjacent.prev))}>
|
||||
<ArrowLeft size={13} weight="light" />
|
||||
</button>
|
||||
|
||||
{#if sliderMax > 1}
|
||||
<div
|
||||
class="slider-wrap"
|
||||
class:dragging={readerState.sliderDragging}
|
||||
role="slider"
|
||||
aria-valuenow={sliderPage}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={sliderMax}
|
||||
tabindex="-1"
|
||||
onmouseenter={() => readerState.sliderHover = true}
|
||||
onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }}
|
||||
onmousedown={(e) => {
|
||||
readerState.sliderDragging = true;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
|
||||
}}
|
||||
onmousemove={(e) => {
|
||||
if (!readerState.sliderDragging) return;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
|
||||
}}
|
||||
onmouseup={() => readerState.sliderDragging = false}
|
||||
>
|
||||
<div class="slider-track-bg">
|
||||
<div class="slider-fill" style={rtl ? `width:${100 - sliderPct}%;margin-left:auto` : `width:${sliderPct}%`}></div>
|
||||
</div>
|
||||
<div class="slider-thumb" style="left:{sliderPct}%"></div>
|
||||
|
||||
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
|
||||
{@const bOrd = rtl ? lastPage - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber}
|
||||
{@const bPct = lastPage > 1 ? ((bOrd - 1) / (lastPage - 1)) * 100 : 0}
|
||||
<div class="slider-checkpoint bookmark-checkpoint" style="left:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
|
||||
{/if}
|
||||
|
||||
{#each activeChapterMarkers as m (m.id)}
|
||||
{@const mOrd = rtl ? lastPage - m.pageNumber + 1 : m.pageNumber}
|
||||
{@const mPct = lastPage > 1 ? ((mOrd - 1) / (lastPage - 1)) * 100 : 0}
|
||||
<div class="slider-checkpoint marker-checkpoint" style="left:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
|
||||
{/each}
|
||||
|
||||
{#if readerState.sliderHover || readerState.sliderDragging}
|
||||
<div class="slider-tooltip" style="left:{sliderPct}%">
|
||||
{sliderPage} / {sliderMax}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button class="nav-btn" onclick={onGoNext}
|
||||
disabled={loading || (style === "longstrip" ? !adjacent.next : (sliderPage === sliderMax && !adjacent.next))}>
|
||||
<ArrowRight size={13} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bottombar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-top: 1px solid var(--border-dim); background: var(--bg-void); flex-shrink: 0; transition: opacity 0.25s ease; }
|
||||
.bottombar.hidden { opacity: 0; pointer-events: none; }
|
||||
|
||||
.nav-btn { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; flex-shrink: 0; border-radius: var(--radius-md); border: 1px solid var(--border-strong); color: var(--text-muted); transition: background var(--t-base), color var(--t-base); }
|
||||
.nav-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); }
|
||||
.nav-btn:disabled { opacity: 0.25; cursor: default; }
|
||||
|
||||
.slider-wrap { flex: 1; position: relative; display: flex; align-items: center; height: 34px; cursor: pointer; }
|
||||
.slider-track-bg { position: absolute; left: 0; right: 0; height: 3px; background: var(--border-strong); border-radius: 2px; pointer-events: none; }
|
||||
.slider-fill { height: 100%; background: var(--accent-fg); border-radius: 2px; transition: width 0.05s linear; position: relative; }
|
||||
.slider-checkpoint { position: absolute; top: 50%; width: 4px; height: 10px; border-radius: 2px; transform: translate(-50%, -50%); pointer-events: none; z-index: 1; }
|
||||
.slider-thumb { position: absolute; top: 50%; transform: translate(-50%, -50%); width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); pointer-events: none; z-index: 2; box-shadow: 0 0 0 2px rgba(0,0,0,0.5); transition: transform var(--t-fast); }
|
||||
.slider-wrap:hover .slider-thumb, .slider-wrap.dragging .slider-thumb { transform: translate(-50%, -50%) scale(1.3); }
|
||||
.bookmark-checkpoint { background: #ffffff; opacity: 0.8; }
|
||||
.marker-checkpoint { opacity: 0.85; }
|
||||
.slider-tooltip { position: absolute; bottom: calc(100% + 2px); transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); }
|
||||
.slider-wrap:hover .slider-track-bg, .slider-wrap.dragging .slider-track-bg { height: 5px; }
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
import { gql } from "@api/client";
|
||||
import { store, addHistory, addBookmark, removeBookmark,
|
||||
checkAndMarkCompleted, DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
||||
import { MARK_CHAPTER_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters";
|
||||
import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
|
||||
|
||||
const AVG_MIN_PER_PAGE = 0.33;
|
||||
|
||||
export function getMangaPrefs() {
|
||||
const mangaId = store.activeManga?.id;
|
||||
if (!mangaId) return DEFAULT_MANGA_PREFS;
|
||||
return { ...DEFAULT_MANGA_PREFS, ...(store.settings.mangaPrefs?.[mangaId] ?? {}) };
|
||||
}
|
||||
|
||||
export function markChapterRead(id: number, markedRead: Set<number>) {
|
||||
if (markedRead.has(id)) return;
|
||||
markedRead.add(id);
|
||||
const chapter = store.activeChapterList.find(c => c.id === id) ?? store.activeChapter;
|
||||
const pages = chapter?.pageCount ?? store.pageUrls.length ?? 15;
|
||||
const minutes = Math.max(1, Math.round(pages * AVG_MIN_PER_PAGE));
|
||||
if (store.activeManga && chapter) {
|
||||
addHistory(
|
||||
{ mangaId: store.activeManga.id, mangaTitle: store.activeManga.title, thumbnailUrl: store.activeManga.thumbnailUrl, chapterId: id, chapterName: chapter.name, readAt: Date.now() },
|
||||
true, minutes,
|
||||
);
|
||||
}
|
||||
gql(MARK_CHAPTER_READ, { id, isRead: true })
|
||||
.then(() => {
|
||||
const mangaId = store.activeManga?.id;
|
||||
if (!mangaId) return;
|
||||
const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c);
|
||||
checkAndMarkCompleted(mangaId, updated);
|
||||
const prefs = getMangaPrefs();
|
||||
if (prefs.deleteOnRead) {
|
||||
const ch = store.activeChapterList.find(c => c.id === id);
|
||||
if (ch?.isDownloaded) {
|
||||
const delayMs = (prefs.deleteDelayHours ?? 0) * 3_600_000;
|
||||
const doDelete = () => gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [id] }).catch(console.error);
|
||||
if (delayMs === 0) doDelete(); else setTimeout(doDelete, delayMs);
|
||||
}
|
||||
}
|
||||
if (prefs.downloadAhead > 0) {
|
||||
const list = store.activeChapterList;
|
||||
const idx = list.findIndex(c => c.id === id);
|
||||
if (idx >= 0) {
|
||||
const toQueue = list.slice(idx + 1, idx + 1 + prefs.downloadAhead).filter(c => !c.isDownloaded && !c.isRead).map(c => c.id);
|
||||
if (toQueue.length) gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: toQueue }).catch(console.error);
|
||||
}
|
||||
}
|
||||
if (prefs.maxKeepChapters > 0) {
|
||||
const downloaded = store.activeChapterList.filter(c => c.isDownloaded).sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const excess = downloaded.slice(0, Math.max(0, downloaded.length - prefs.maxKeepChapters));
|
||||
if (excess.length) gql(DELETE_DOWNLOADED_CHAPTERS, { ids: excess.map(c => c.id) }).catch(console.error);
|
||||
}
|
||||
})
|
||||
.catch(e => { markedRead.delete(id); console.error(e); });
|
||||
}
|
||||
|
||||
export function toggleBookmark(
|
||||
displayChapter: import("@types").Chapter | null | undefined,
|
||||
pageNumber: number,
|
||||
) {
|
||||
const ch = displayChapter;
|
||||
const manga = store.activeManga;
|
||||
if (!ch || !manga) return;
|
||||
const isBookmarked = !!store.bookmarks.find(
|
||||
b => b.mangaId === manga.id && b.chapterId === ch.id && b.pageNumber === pageNumber,
|
||||
);
|
||||
if (isBookmarked) {
|
||||
removeBookmark(ch.id);
|
||||
} else {
|
||||
const existing = store.bookmarks.find(b => b.mangaId === manga.id && b.chapterId !== ch.id);
|
||||
if (existing) removeBookmark(existing.chapterId);
|
||||
addBookmark({ mangaId: manga.id, mangaTitle: manga.title, thumbnailUrl: manga.thumbnailUrl, chapterId: ch.id, chapterName: ch.name, pageNumber });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { store, openReader } from "@store/state.svelte";
|
||||
import { readerState } from "../store/readerState.svelte";
|
||||
import { fetchPages } from "./pageLoader";
|
||||
|
||||
export function scheduleResumeDismiss() {
|
||||
setTimeout(() => { readerState.resumeFading = true; }, 1500);
|
||||
setTimeout(() => { readerState.resumeVisible = false; readerState.resumeFading = false; }, 2500);
|
||||
}
|
||||
|
||||
export async function loadChapter(
|
||||
id: number,
|
||||
useBlob: boolean,
|
||||
abortCtrl: { current: AbortController | null },
|
||||
startAtLastPage: { current: boolean },
|
||||
markedRead: Set<number>,
|
||||
adjacent: { next: { id: number } | null },
|
||||
) {
|
||||
abortCtrl.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortCtrl.current = ctrl;
|
||||
startAtLastPage.current = false;
|
||||
markedRead.clear();
|
||||
readerState.resetForChapter();
|
||||
store.pageUrls = [];
|
||||
|
||||
const bookmark = store.bookmarks.find(b => b.chapterId === id);
|
||||
const resumeTo = bookmark ? bookmark.pageNumber : 0;
|
||||
readerState.resumePage = resumeTo > 1 ? resumeTo : 0;
|
||||
readerState.resumeDismissed = false;
|
||||
readerState.resumeVisible = resumeTo > 1;
|
||||
if (resumeTo > 1) scheduleResumeDismiss();
|
||||
|
||||
store.pageNumber = 1;
|
||||
try {
|
||||
const urls = await fetchPages(id, useBlob, ctrl.signal, resumeTo > 1 ? resumeTo - 1 : 0);
|
||||
if (ctrl.signal.aborted) return;
|
||||
store.pageUrls = urls;
|
||||
if (startAtLastPage.current) store.pageNumber = urls.length;
|
||||
else if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
|
||||
readerState.pageReady = true;
|
||||
readerState.loading = false;
|
||||
if (adjacent.next) fetchPages(adjacent.next.id, useBlob).catch(() => {});
|
||||
} catch (e: any) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
readerState.error = e instanceof Error ? e.message : String(e);
|
||||
readerState.loading = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export { readerState } from "./store/readerState.svelte";
|
||||
export type { PageStyle } from "./store/readerState.svelte";
|
||||
export { PAGE_STYLES, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX } from "./store/readerState.svelte";
|
||||
|
||||
export { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups, clearPageCache } from "./lib/pageLoader";
|
||||
export { setupScrollTracking, appendNextChapter } from "./lib/scrollHandler";
|
||||
export type { StripChapter, ScrollHandlerCallbacks } from "./lib/scrollHandler";
|
||||
export { createReaderKeyHandler } from "./lib/readerKeybinds";
|
||||
export type { ReaderKeyActions } from "./lib/readerKeybinds";
|
||||
|
||||
export { markChapterRead, getMangaPrefs, toggleBookmark } from "./lib/chapterActions";
|
||||
export { goForward, goBack, jumpToPage, animateFade } from "./lib/navigation";
|
||||
export { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "./lib/zoomHelpers";
|
||||
export { loadChapter, scheduleResumeDismiss } from "./lib/chapterLoader";
|
||||
@@ -0,0 +1,84 @@
|
||||
import { store, openReader, closeReader } from "@store/state.svelte";
|
||||
import { readerState } from "../store/readerState.svelte";
|
||||
import type { Chapter } from "@types";
|
||||
|
||||
interface Adjacent {
|
||||
prev: Chapter | null;
|
||||
next: Chapter | null;
|
||||
}
|
||||
|
||||
export function advanceGroup(forward: boolean, adjacent: Adjacent, startAtLastPage: () => void) {
|
||||
if (!readerState.pageGroups.length) return;
|
||||
const gi = readerState.pageGroups.findIndex(g => g.includes(store.pageNumber));
|
||||
if (forward) {
|
||||
if (gi < readerState.pageGroups.length - 1) store.pageNumber = readerState.pageGroups[gi + 1][0];
|
||||
else if (adjacent.next) { store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
|
||||
else closeReader();
|
||||
} else {
|
||||
if (gi > 0) store.pageNumber = readerState.pageGroups[gi - 1][0];
|
||||
else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, store.activeChapterList); }
|
||||
}
|
||||
}
|
||||
|
||||
export async function animateFade(fn: () => void) {
|
||||
readerState.fadingOut = true;
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
fn();
|
||||
readerState.fadingOut = false;
|
||||
}
|
||||
|
||||
export function goForward(
|
||||
style: string,
|
||||
adjacent: Adjacent,
|
||||
lastPage: number,
|
||||
onMaybeMarkRead: () => void,
|
||||
startAtLastPage: () => void,
|
||||
) {
|
||||
if (readerState.loading) return;
|
||||
if (style === "longstrip") {
|
||||
if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, store.activeChapterList); }
|
||||
return;
|
||||
}
|
||||
if (style === "double" && readerState.pageGroups.length) { advanceGroup(true, adjacent, startAtLastPage); return; }
|
||||
if (!store.pageUrls.length) return;
|
||||
if (store.pageNumber < lastPage) {
|
||||
if (style === "fade") animateFade(() => { store.pageNumber++; });
|
||||
else store.pageNumber++;
|
||||
} else if (adjacent.next) {
|
||||
onMaybeMarkRead();
|
||||
store.pageNumber = 1;
|
||||
openReader(adjacent.next, store.activeChapterList);
|
||||
} else closeReader();
|
||||
}
|
||||
|
||||
export function goBack(
|
||||
style: string,
|
||||
adjacent: Adjacent,
|
||||
startAtLastPage: () => void,
|
||||
) {
|
||||
if (readerState.loading) return;
|
||||
if (style === "longstrip") {
|
||||
if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, store.activeChapterList); }
|
||||
return;
|
||||
}
|
||||
if (style === "double" && readerState.pageGroups.length) { advanceGroup(false, adjacent, startAtLastPage); return; }
|
||||
if (!store.pageUrls.length) return;
|
||||
if (store.pageNumber > 1) {
|
||||
if (style === "fade") animateFade(() => { store.pageNumber--; });
|
||||
else store.pageNumber--;
|
||||
} else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, store.activeChapterList); }
|
||||
}
|
||||
|
||||
export function jumpToPage(page: number, style: string, lastPage: number, containerEl: HTMLElement | null) {
|
||||
if (style === "longstrip") {
|
||||
const chId = readerState.visibleChapterId ?? store.activeChapter?.id;
|
||||
containerEl?.querySelector<HTMLImageElement>(`img[data-local-page="${page}"][data-chapter="${chId}"]`)?.scrollIntoView({ block: "start" });
|
||||
return;
|
||||
}
|
||||
if (style === "double" && readerState.pageGroups.length) {
|
||||
const group = readerState.pageGroups[page - 1];
|
||||
if (group) store.pageNumber = group[0];
|
||||
} else {
|
||||
store.pageNumber = Math.max(1, Math.min(lastPage, page));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { gql, plainThumbUrl } from "@api/client";
|
||||
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
|
||||
import { dedupeRequest } from "@core/async/batchRequests";
|
||||
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
|
||||
|
||||
export interface PageLoaderOptions {
|
||||
useBlob: () => boolean;
|
||||
}
|
||||
|
||||
const pageCache = new Map<number, string[]>();
|
||||
const inflight = new Map<number, Promise<string[]>>();
|
||||
const resolvedUrlCache = new Map<string, Promise<string>>();
|
||||
const preloadedUrls = new Set<string>();
|
||||
const aspectCache = new Map<string, number>();
|
||||
|
||||
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
|
||||
if (!useBlob) return Promise.resolve(url);
|
||||
if (!resolvedUrlCache.has(url)) resolvedUrlCache.set(url, getBlobUrl(url, priority));
|
||||
return resolvedUrlCache.get(url)!;
|
||||
}
|
||||
|
||||
export function fetchPages(
|
||||
chapterId: number,
|
||||
useBlob: boolean,
|
||||
signal?: AbortSignal,
|
||||
priorityPage = 0,
|
||||
): Promise<string[]> {
|
||||
const cached = pageCache.get(chapterId);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||
|
||||
if (!inflight.has(chapterId)) {
|
||||
const p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
|
||||
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
|
||||
.then(d => {
|
||||
const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p));
|
||||
if (useBlob) {
|
||||
if (urls[priorityPage]) getBlobUrl(urls[priorityPage], urls.length + 999);
|
||||
preloadBlobUrls(urls.filter((_, i) => i !== priorityPage), urls.length);
|
||||
}
|
||||
pageCache.set(chapterId, urls);
|
||||
return urls;
|
||||
})
|
||||
).finally(() => inflight.delete(chapterId));
|
||||
inflight.set(chapterId, p);
|
||||
}
|
||||
|
||||
const base = inflight.get(chapterId)!;
|
||||
if (!signal) return base;
|
||||
return new Promise((resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
|
||||
base.then(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function measureAspect(url: string, useBlob: boolean): Promise<number> {
|
||||
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
|
||||
return resolveUrl(url, useBlob).then(src => new Promise(res => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67;
|
||||
aspectCache.set(url, r);
|
||||
res(r);
|
||||
};
|
||||
img.onerror = () => res(0.67);
|
||||
img.src = src;
|
||||
}));
|
||||
}
|
||||
|
||||
export function preloadImage(url: string, useBlob: boolean): void {
|
||||
if (preloadedUrls.has(url)) return;
|
||||
preloadedUrls.add(url);
|
||||
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
||||
}
|
||||
|
||||
export function buildPageGroups(
|
||||
urls: string[],
|
||||
aspects: number[],
|
||||
offsetSpreads: boolean,
|
||||
): number[][] {
|
||||
const groups: number[][] = [[1]];
|
||||
if (offsetSpreads) groups.push([2]);
|
||||
let i = offsetSpreads ? 3 : 2;
|
||||
while (i <= urls.length) {
|
||||
const a = aspects[i - 1];
|
||||
if (a > 1.2 || i === urls.length) { groups.push([i++]); }
|
||||
else { groups.push([i, i + 1]); i += 2; }
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function clearPageCache(chapterId?: number): void {
|
||||
if (chapterId !== undefined) {
|
||||
pageCache.delete(chapterId);
|
||||
inflight.delete(chapterId);
|
||||
} else {
|
||||
pageCache.clear();
|
||||
inflight.clear();
|
||||
resolvedUrlCache.clear();
|
||||
preloadedUrls.clear();
|
||||
aspectCache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "@core/keybinds";
|
||||
import type { Keybinds } from "@core/keybinds";
|
||||
|
||||
export interface ReaderKeyActions {
|
||||
goNext: () => void;
|
||||
goPrev: () => void;
|
||||
closeReader: () => void;
|
||||
goToPage: (page: number) => void;
|
||||
lastPage: () => number;
|
||||
adjustZoom: (delta: number) => void;
|
||||
resetZoom: () => void;
|
||||
cycleStyle: () => void;
|
||||
toggleDirection: () => void;
|
||||
openSettings: () => void;
|
||||
toggleBookmark: () => void;
|
||||
toggleMarker: () => void;
|
||||
chapterNext: () => void;
|
||||
chapterPrev: () => void;
|
||||
closePopovers: () => boolean;
|
||||
getKeybinds: () => Keybinds;
|
||||
}
|
||||
|
||||
const ZOOM_STEP = 0.10;
|
||||
|
||||
export function createReaderKeyHandler(actions: ReaderKeyActions): (e: KeyboardEvent) => void {
|
||||
return function onKey(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (actions.closePopovers()) return;
|
||||
actions.closeReader();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.ctrlKey) {
|
||||
if (e.key === "=" || e.key === "+") { e.preventDefault(); actions.adjustZoom(ZOOM_STEP); return; }
|
||||
if (e.key === "-") { e.preventDefault(); actions.adjustZoom(-ZOOM_STEP); return; }
|
||||
if (e.key === "0") { e.preventDefault(); actions.resetZoom(); return; }
|
||||
}
|
||||
|
||||
const kb = actions.getKeybinds();
|
||||
|
||||
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); actions.closeReader(); }
|
||||
else if (matchesKeybind(e, kb.turnPageRight)) { e.preventDefault(); actions.goNext(); }
|
||||
else if (matchesKeybind(e, kb.turnPageLeft)) { e.preventDefault(); actions.goPrev(); }
|
||||
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); actions.goToPage(1); }
|
||||
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); actions.goToPage(actions.lastPage()); }
|
||||
else if (matchesKeybind(e, kb.turnChapterRight)) { e.preventDefault(); actions.chapterNext(); }
|
||||
else if (matchesKeybind(e, kb.turnChapterLeft)) { e.preventDefault(); actions.chapterPrev(); }
|
||||
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); actions.cycleStyle(); }
|
||||
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); actions.toggleDirection(); }
|
||||
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
|
||||
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); actions.openSettings(); }
|
||||
else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); actions.toggleBookmark(); }
|
||||
else if (matchesKeybind(e, kb.toggleMarker)) { e.preventDefault(); actions.toggleMarker(); }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
export const READ_LINE_PCT = 0.50;
|
||||
|
||||
export interface StripChapter {
|
||||
chapterId: number;
|
||||
chapterName: string;
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
export interface ScrollHandlerCallbacks {
|
||||
onPageChange: (page: number) => void;
|
||||
onChapterChange: (chapterId: number) => void;
|
||||
onMarkRead: (chapterId: number) => void;
|
||||
onAppend: () => void;
|
||||
getStripChapters: () => StripChapter[];
|
||||
getPageUrls: () => string[];
|
||||
shouldAutoMark: () => boolean;
|
||||
}
|
||||
|
||||
export function setupScrollTracking(
|
||||
containerEl: HTMLElement,
|
||||
callbacks: ScrollHandlerCallbacks,
|
||||
): () => void {
|
||||
const {
|
||||
onPageChange, onChapterChange, onMarkRead,
|
||||
onAppend, getStripChapters, getPageUrls, shouldAutoMark,
|
||||
} = callbacks;
|
||||
|
||||
function onScroll() {
|
||||
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||||
if (!imgs.length) return;
|
||||
|
||||
const containerTop = containerEl.getBoundingClientRect().top;
|
||||
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
|
||||
|
||||
let activePage: number | null = null;
|
||||
let activeChId: number | null = null;
|
||||
|
||||
for (const img of imgs) {
|
||||
if (img.getBoundingClientRect().top <= readLineY) {
|
||||
activePage = Number(img.dataset.localPage);
|
||||
activeChId = Number(img.dataset.chapter);
|
||||
} else break;
|
||||
}
|
||||
|
||||
if (activePage === null) {
|
||||
activePage = Number(imgs[0].dataset.localPage);
|
||||
activeChId = Number(imgs[0].dataset.chapter);
|
||||
}
|
||||
|
||||
if (activePage !== null) onPageChange(activePage);
|
||||
if (activeChId) onChapterChange(activeChId);
|
||||
|
||||
if (shouldAutoMark() && activePage !== null && activeChId) {
|
||||
const chunks = getStripChapters();
|
||||
const chunk = chunks.find(c => c.chapterId === activeChId);
|
||||
const total = chunk ? chunk.urls.length : getPageUrls().length;
|
||||
if (total > 0 && activePage >= total) onMarkRead(activeChId);
|
||||
}
|
||||
|
||||
const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40;
|
||||
if (atBottom && shouldAutoMark()) {
|
||||
const chunks = getStripChapters();
|
||||
const last = chunks[chunks.length - 1];
|
||||
if (last) onMarkRead(last.chapterId);
|
||||
}
|
||||
}
|
||||
|
||||
function onScrollAppend() {
|
||||
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
|
||||
if (pct >= 0.80) onAppend();
|
||||
}
|
||||
|
||||
containerEl.addEventListener("scroll", onScroll, { passive: true });
|
||||
containerEl.addEventListener("scroll", onScrollAppend, { passive: true });
|
||||
|
||||
return () => {
|
||||
containerEl.removeEventListener("scroll", onScroll);
|
||||
containerEl.removeEventListener("scroll", onScrollAppend);
|
||||
};
|
||||
}
|
||||
|
||||
export function appendNextChapter(
|
||||
stripChapters: StripChapter[],
|
||||
chapterList: { id: number; name: string }[],
|
||||
fetchPages: (chapterId: number) => Promise<string[]>,
|
||||
preloadImage: (url: string) => void,
|
||||
onAppended: (next: StripChapter) => void,
|
||||
onDone: () => void,
|
||||
): void {
|
||||
if (!stripChapters.length) return;
|
||||
|
||||
const lastChunk = stripChapters[stripChapters.length - 1];
|
||||
const lastIdx = chapterList.findIndex(c => c.id === lastChunk.chapterId);
|
||||
if (lastIdx < 0 || lastIdx >= chapterList.length - 1) return;
|
||||
|
||||
const next = chapterList[lastIdx + 1];
|
||||
if (!next || stripChapters.some(c => c.chapterId === next.id)) return;
|
||||
|
||||
fetchPages(next.id)
|
||||
.then(urls => {
|
||||
urls.slice(0, 6).forEach(preloadImage);
|
||||
return urls;
|
||||
})
|
||||
.then(urls => {
|
||||
if (stripChapters.some(c => c.chapterId === next.id)) { onDone(); return; }
|
||||
onAppended({ chapterId: next.id, chapterName: next.name, urls });
|
||||
onDone();
|
||||
})
|
||||
.catch(() => onDone());
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readerState } from "../store/readerState.svelte";
|
||||
|
||||
export function clampZoom(z: number): number {
|
||||
const { ZOOM_MIN, ZOOM_MAX } = readerState;
|
||||
return Math.round(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)) * 1000) / 1000;
|
||||
}
|
||||
|
||||
export function captureZoomAnchor(
|
||||
containerEl: HTMLElement | null,
|
||||
style: string,
|
||||
out: { el: HTMLElement | null; offset: number },
|
||||
) {
|
||||
if (!containerEl || style !== "longstrip") return;
|
||||
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
|
||||
const containerTop = containerEl.getBoundingClientRect().top;
|
||||
for (const img of imgs) {
|
||||
const rect = img.getBoundingClientRect();
|
||||
if (rect.bottom > containerTop) {
|
||||
out.el = img;
|
||||
out.offset = rect.top - containerTop;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreZoomAnchor(
|
||||
containerEl: HTMLElement | null,
|
||||
out: { el: HTMLElement | null; offset: number },
|
||||
) {
|
||||
if (!out.el || !containerEl) return;
|
||||
const el = out.el;
|
||||
out.el = null;
|
||||
requestAnimationFrame(() => {
|
||||
const containerTop = containerEl!.getBoundingClientRect().top;
|
||||
const newRect = el.getBoundingClientRect();
|
||||
containerEl!.scrollTop += (newRect.top - containerTop) - out.offset;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { MarkerColor } from "@store/state.svelte";
|
||||
import type { StripChapter } from "../lib/scrollHandler";
|
||||
|
||||
export const PAGE_STYLES = ["single", "fade", "double", "longstrip"] as const;
|
||||
export type PageStyle = typeof PAGE_STYLES[number];
|
||||
|
||||
export const MARKER_COLORS: MarkerColor[] = ["yellow", "red", "blue", "green", "purple"];
|
||||
export const MARKER_COLOR_HEX: Record<MarkerColor, string> = {
|
||||
yellow: "#c4a94a",
|
||||
red: "#c47a7a",
|
||||
blue: "#7a9ec4",
|
||||
green: "#7aab7a",
|
||||
purple: "#a07ac4",
|
||||
};
|
||||
|
||||
export const ZOOM_STEP = 0.05;
|
||||
export const ZOOM_MIN = 0.1;
|
||||
export const ZOOM_MAX = 1.0;
|
||||
|
||||
class ReaderState {
|
||||
loading = $state(true);
|
||||
error = $state<string | null>(null);
|
||||
pageReady = $state(false);
|
||||
pageGroups = $state<number[][]>([]);
|
||||
stripChapters = $state<StripChapter[]>([]);
|
||||
visibleChapterId = $state<number | null>(null);
|
||||
|
||||
uiVisible = $state(true);
|
||||
isFullscreen = $state(false);
|
||||
|
||||
dlOpen = $state(false);
|
||||
zoomOpen = $state(false);
|
||||
winOpen = $state(false);
|
||||
nextN = $state(5);
|
||||
dlBusy = $state(false);
|
||||
|
||||
fadingOut = $state(false);
|
||||
sliderDragging = $state(false);
|
||||
sliderHover = $state(false);
|
||||
|
||||
resumePage = $state(0);
|
||||
resumeDismissed = $state(false);
|
||||
resumeFading = $state(false);
|
||||
resumeVisible = $state(false);
|
||||
stripResumeReady = $state(false);
|
||||
|
||||
markerOpen = $state(false);
|
||||
markerNote = $state("");
|
||||
markerColor = $state<MarkerColor>("yellow");
|
||||
markerEditId = $state("");
|
||||
|
||||
inspectScale = $state(1);
|
||||
inspectPanX = $state(0);
|
||||
inspectPanY = $state(0);
|
||||
|
||||
containerWidth = $state(0);
|
||||
|
||||
resetForChapter() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.pageReady = false;
|
||||
this.pageGroups = [];
|
||||
this.stripChapters = [];
|
||||
this.visibleChapterId = null;
|
||||
this.fadingOut = false;
|
||||
this.markerOpen = false;
|
||||
}
|
||||
|
||||
resetResume() {
|
||||
this.resumePage = 0;
|
||||
this.resumeDismissed = false;
|
||||
this.resumeVisible = false;
|
||||
this.stripResumeReady = false;
|
||||
}
|
||||
|
||||
resetInspect() {
|
||||
this.inspectScale = 1;
|
||||
this.inspectPanX = 0;
|
||||
this.inspectPanY = 0;
|
||||
}
|
||||
|
||||
closeAllPopovers(): boolean {
|
||||
if (this.markerOpen) { this.markerOpen = false; return true; }
|
||||
if (this.zoomOpen) { this.zoomOpen = false; return true; }
|
||||
if (this.dlOpen) { this.dlOpen = false; return true; }
|
||||
if (this.winOpen) { this.winOpen = false; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
openMarker(editId: string, note: string, color: MarkerColor) {
|
||||
this.markerEditId = editId;
|
||||
this.markerNote = note;
|
||||
this.markerColor = color;
|
||||
this.markerOpen = true;
|
||||
this.zoomOpen = false;
|
||||
this.dlOpen = false;
|
||||
this.winOpen = false;
|
||||
}
|
||||
|
||||
clearMarkerPopover() {
|
||||
this.markerOpen = false;
|
||||
this.markerNote = "";
|
||||
this.markerEditId = "";
|
||||
}
|
||||
}
|
||||
|
||||
export const readerState = new ReaderState();
|
||||
Reference in New Issue
Block a user