Chore: Restructure Repository for SvelteKit

This commit is contained in:
Youwes09
2026-05-22 04:04:59 -05:00
parent bf071dcfc7
commit 8cef74bb98
266 changed files with 5093 additions and 396 deletions
@@ -0,0 +1,710 @@
<script lang="ts">
import { store } from "@store/state.svelte";
import { readerState } from "../store/readerState.svelte";
import type { StripChapter } from "../lib/scrollHandler";
import { createPinchTracker } from "../lib/pinchZoom";
import type { PinchTracker } from "../lib/pinchZoom";
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;
pinchZoomEnabled: boolean;
chapterEpoch: number;
barPosition: "top" | "left" | "right";
onGetZoom: () => number;
onSetZoom: (z: number) => void;
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, pinchZoomEnabled, chapterEpoch, barPosition, onGetZoom, onSetZoom,
resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
}: Props = $props();
const LOAD_RADIUS = 5;
const UNLOAD_RADIUS = 10;
type FlatPage = { chapterId: number; chapterName: string; localIndex: number; url: string; total: number };
const flatPages = $derived.by<FlatPage[]>(() => {
const out: FlatPage[] = [];
for (const chunk of stripToRender) {
for (let i = 0; i < chunk.urls.length; i++) {
out.push({ chapterId: chunk.chapterId, chapterName: chunk.chapterName, localIndex: i, url: chunk.urls[i], total: chunk.urls.length });
}
}
return out;
});
let loadedSet = $state(new Set<number>());
let resolvedSrc = $state<Record<number, string>>({});
let revokeQueue: string[] = [];
let observer: IntersectionObserver | null = null;
const elementIndex = new Map<Element, number>();
let viewportCenter = $state(0);
function scheduleRevoke(src: string) {
if (!src || !src.startsWith("blob:")) return;
revokeQueue.push(src);
requestAnimationFrame(() => {
const url = revokeQueue.shift();
if (url) {
try { URL.revokeObjectURL(url); } catch { }
}
});
}
function loadPage(idx: number) {
if (loadedSet.has(idx)) return;
const page = flatPages[idx];
if (!page) return;
const newSet = new Set(loadedSet);
newSet.add(idx);
loadedSet = newSet;
const priority = (page.localIndex < 8 && page.chapterId === flatPages[0]?.chapterId) ? 8 - page.localIndex : 0;
resolveUrl(page.url, priority).then(src => {
if (loadedSet.has(idx)) {
resolvedSrc = { ...resolvedSrc, [idx]: src };
} else {
scheduleRevoke(src);
}
});
}
function unloadPage(idx: number) {
if (!loadedSet.has(idx)) return;
const newSet = new Set(loadedSet);
newSet.delete(idx);
loadedSet = newSet;
const oldSrc = resolvedSrc[idx];
if (oldSrc) {
const next = { ...resolvedSrc };
delete next[idx];
resolvedSrc = next;
scheduleRevoke(oldSrc);
}
}
function recalcWindow() {
const center = viewportCenter;
const lo = center - LOAD_RADIUS;
const hi = center + LOAD_RADIUS;
const evictLo = center - UNLOAD_RADIUS;
const evictHi = center + UNLOAD_RADIUS;
for (let i = 0; i < flatPages.length; i++) {
if (i >= lo && i <= hi) {
loadPage(i);
} else if (i < evictLo || i > evictHi) {
unloadPage(i);
}
}
}
$effect(() => {
void viewportCenter;
recalcWindow();
});
$effect(() => {
void flatPages.length;
recalcWindow();
});
function setupObserver(containerEl: HTMLElement) {
observer?.disconnect();
elementIndex.clear();
observer = new IntersectionObserver(
(entries) => {
let best = -1;
let bestRatio = -1;
for (const entry of entries) {
const idx = elementIndex.get(entry.target);
if (idx === undefined) continue;
if (entry.isIntersecting && entry.intersectionRatio > bestRatio) {
bestRatio = entry.intersectionRatio;
best = idx;
}
}
if (best >= 0 && best !== viewportCenter) {
viewportCenter = best;
}
},
{
root: containerEl,
rootMargin: "0px",
threshold: [0, 0.1, 0.5, 1.0],
}
);
}
function observePage(el: HTMLDivElement, idx: number) {
elementIndex.set(el, idx);
observer?.observe(el);
return {
update(newIdx: number) {
elementIndex.set(el, newIdx);
},
destroy() {
observer?.unobserve(el);
elementIndex.delete(el);
}
};
}
$effect(() => {
void chapterEpoch;
loadedSet = new Set<number>();
resolvedSrc = {};
const resume = readerState.resumePage;
viewportCenter = resume > 1 ? resume - 1 : 0;
});
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;
let stripDragging = $state(false);
let stripDragMoved = false;
let stripDragStartY = 0;
let stripScrollStart = 0;
let autoScrollPaused = false;
let autoScrollPauseTimer: ReturnType<typeof setTimeout> | null = null;
let midScrollActive = $state(false);
let midScrollOriginY = $state(0);
let midScrollOriginX = $state(0);
let midScrollCurrentY = 0;
let midScrollRaf: number | null = null;
// Speed level 0-5 for the indicator bar
const midScrollSpeedLevel = $derived.by(() => {
if (!midScrollActive) return 0;
// recomputes when midScrollOriginY changes; actual dy read in RAF so this is just for display
return 0; // will be updated imperatively
});
let midScrollDisplayLevel = $state(0);
function startMidScroll(originY: number, originX: number) {
midScrollActive = true;
midScrollOriginY = originY;
midScrollOriginX = originX;
midScrollDisplayLevel = 0;
if (midScrollRaf) cancelAnimationFrame(midScrollRaf);
const tick = () => {
if (!midScrollActive || !containerEl) return;
const dy = midScrollCurrentY - midScrollOriginY;
const deadZone = 24;
const excess = Math.max(0, Math.abs(dy) - deadZone);
const speed = Math.sign(dy) * excess * 0.12;
containerEl.scrollTop += speed;
midScrollDisplayLevel = Math.sign(dy) * Math.min(5, Math.floor(excess / 30));
midScrollRaf = requestAnimationFrame(tick);
};
midScrollRaf = requestAnimationFrame(tick);
}
function stopMidScroll() {
midScrollActive = false;
midScrollDisplayLevel = 0;
if (midScrollRaf) { cancelAnimationFrame(midScrollRaf); midScrollRaf = null; }
}
function pauseAutoScroll() {
autoScrollPaused = true;
if (autoScrollPauseTimer) clearTimeout(autoScrollPauseTimer);
autoScrollPauseTimer = setTimeout(() => { autoScrollPaused = false; }, 2500);
}
$effect(() => {
if (style !== "longstrip" || !store.settings.autoScroll) return;
let rafId: number;
const tick = () => {
if (!autoScrollPaused && containerEl) containerEl.scrollTop += (store.settings.autoScrollSpeed ?? 5) * 0.5;
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
});
let pinch: PinchTracker | null = null;
$effect(() => {
if (pinchZoomEnabled) {
pinch = createPinchTracker({
getZoom: onGetZoom,
setZoom: onSetZoom,
getInspectScale: () => readerState.inspectScale,
setInspectScale: (s) => { readerState.inspectScale = s; },
resetInspectPan: () => { readerState.inspectPanX = 0; readerState.inspectPanY = 0; },
isLongstrip: () => style === "longstrip",
});
} else {
pinch = null;
}
});
export function onInspectMouseDown(e: MouseEvent) {
if ((e.target as Element).closest(".bar")) return;
if (e.button === 1 && style === "longstrip") {
e.preventDefault();
if (midScrollActive) { stopMidScroll(); } else {
// pause regular auto-scroll while mid-scroll is active
store.settings.autoScroll = false;
startMidScroll(e.clientY, e.clientX);
}
return;
}
if (style === "longstrip") {
stripDragging = true;
stripDragMoved = false;
stripDragStartY = e.clientY;
stripScrollStart = containerEl?.scrollTop ?? 0;
pauseAutoScroll();
e.preventDefault();
return;
}
if (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) {
midScrollCurrentY = e.clientY;
if (stripDragging) {
const dy = e.clientY - stripDragStartY;
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
return;
}
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() {
stripDragging = false;
inspectDragging = false;
}
export function onPointerDown(e: PointerEvent) {
if ((e.target as Element).closest(".bar")) return;
pinch?.onPointerDown(e);
}
export function onPointerMove(e: PointerEvent) {
if (pinch?.isPinching()) {
pinch.onPointerMove(e);
return;
}
if (stripDragging) {
const dy = e.clientY - stripDragStartY;
if (!stripDragMoved && Math.abs(dy) > 4) stripDragMoved = true;
if (containerEl) containerEl.scrollTop = stripScrollStart - dy;
}
if (inspectDragging) {
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 onPointerUp(e: PointerEvent) {
pinch?.onPointerUp(e);
if (!pinch?.isPinching()) {
stripDragging = false;
inspectDragging = false;
}
}
export function handleWheel(e: WheelEvent) {
if (style === "longstrip") {
if (e.ctrlKey) { onWheel(e); }
else pauseAutoScroll();
return;
}
if (!e.ctrlKey) { onWheel(e); 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") {
if (stripDragMoved) { stripDragMoved = false; return; }
return;
}
if (inspectDragMoved) { inspectDragMoved = false; return; }
if (stripDragMoved) { stripDragMoved = false; return; }
onTap(e);
}
function setContainer(el: HTMLDivElement) {
containerEl = el;
bindContainer(el);
if (style === "longstrip") setupObserver(el);
}
$effect(() => {
if (style === "longstrip" && containerEl) {
setupObserver(containerEl);
} else if (style !== "longstrip") {
observer?.disconnect();
observer = null;
stopMidScroll();
}
});
</script>
<div
use:setContainer
class="viewer"
class:strip={style === "longstrip"}
class:inspect-active={readerState.inspectScale > 1}
class:midscroll-active={midScrollActive}
style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""}
role="presentation"
tabindex="-1"
onclick={handleTap}
onauxclick={(e) => { if (e.button === 1 && style === "longstrip") e.preventDefault(); }}
ondblclick={() => { if (tapToToggleBar) onToggleUi(); }}
onmousedown={onInspectMouseDown}
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
style:cursor={style === "longstrip" ? (stripDragging ? "grabbing" : "grab") : undefined}
onkeydown={(e) => {
if (e.key === " " && style === "longstrip") { e.preventDefault(); store.settings.autoScroll = !store.settings.autoScroll; return; }
if ((e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown") && style !== "longstrip") e.preventDefault();
}}
>
{#if midScrollActive}
<div class="midscroll-bar" class:midscroll-bar-right={barPosition !== "right"} class:midscroll-bar-left={barPosition === "right"}>
<div class="midscroll-segments">
{#each [5,4,3,2,1] as n}
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel < 0 && -midScrollDisplayLevel >= n}></div>
{/each}
<div class="midscroll-origin-dot"></div>
{#each [1,2,3,4,5] as n}
<div class="midscroll-seg" class:midscroll-seg-lit={midScrollDisplayLevel > 0 && midScrollDisplayLevel >= n}></div>
{/each}
</div>
<button class="midscroll-stop" onclick={stopMidScroll} title="Stop (middle click)">
<svg width="8" height="8" viewBox="0 0 8 8"><rect x="0" y="0" width="8" height="8" rx="1" fill="currentColor"/></svg>
</button>
</div>
{/if}
{#if loading}
<div class="center-overlay">
<div class="page-loader page-loader-single" aria-hidden="true"><svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg></div>
</div>
{/if}
{#if error}
<div class="center-overlay"><p class="error-msg">{error}</p></div>
{/if}
{#key chapterEpoch}
{#if style === "longstrip"}
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
{@const src = resolvedSrc[gi]}
{@const isLoaded = loadedSet.has(gi)}
<div
class="strip-slot"
use:observePage={gi}
data-gi={gi}
>
{#if isLoaded && src}
<img
src={src}
alt="{page.chapterName} Page {page.localIndex + 1}"
data-local-page={page.localIndex + 1}
data-chapter={page.chapterId}
data-total={page.total}
class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}"
loading="eager"
decoding="async"
draggable="false"
onload={(e) => {
const img = e.currentTarget as HTMLImageElement;
const slot = img.closest<HTMLElement>(".strip-slot");
if (slot && img.naturalWidth > 0) {
slot.style.setProperty("--aspect", String(img.naturalWidth / img.naturalHeight));
}
}}
/>
{:else}
<div class="strip-placeholder" aria-hidden="true">
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
</div>
{/if}
</div>
{/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)}
<div class="page-loader page-loader-single" aria-hidden="true">
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
</div>
{:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" draggable="false" />
{/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 (pg)}
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
<div class="page-loader page-half {i === 0 ? 'gap-left' : 'gap-right'}" aria-hidden="true">
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
</div>
{:then src}
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" draggable="false" />
{/await}
{/each}
</div>
{:else}
<div class="center-overlay">
<div class="page-loader page-loader-single" aria-hidden="true">
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
</div>
</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)}
<div class="page-loader page-loader-single" aria-hidden="true">
<svg class="panel-skeleton" viewBox="0 0 100 150" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet"><rect class="ps-r ps-r1" x="2" y="2" width="62" height="88" rx="1"/><rect class="ps-r ps-r2" x="68" y="2" width="30" height="42" rx="1"/><rect class="ps-r ps-r3" x="68" y="48" width="30" height="42" rx="1"/><rect class="ps-r ps-r4" x="2" y="94" width="44" height="54" rx="1"/><rect class="ps-r ps-r5" x="50" y="94" width="48" height="54" rx="1"/></svg>
</div>
{:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" draggable="false" />
{/await}
</div>
{/if}
{/key}
</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; touch-action: pan-x pan-y; zoom: calc(1 / var(--ui-zoom, 1)); }
.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; }
:global(.pinch-active) .viewer { touch-action: none; }
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
.strip-slot {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.strip-placeholder {
width: var(--effective-width, 100%);
max-width: var(--effective-width, 100%);
aspect-ratio: var(--aspect, 0.667);
border-radius: var(--radius-sm);
display: flex;
align-items: stretch;
}
.page-loader {
border-radius: var(--radius-sm);
display: flex;
align-items: stretch;
}
.page-loader-single {
width: min(100%, var(--effective-width, 100%));
max-width: var(--effective-width, 100%);
max-height: calc(var(--visual-vh, 100vh) - 80px);
aspect-ratio: 2 / 3;
}
.panel-skeleton { width: 100%; height: 100%; }
.panel-skeleton :global(.ps-r) {
stroke: var(--border-strong);
stroke-width: 0.8;
fill: none;
stroke-dasharray: 400;
stroke-dashoffset: 400;
animation: ps-shimmer 2s ease-in-out infinite;
}
.panel-skeleton :global(.ps-r1) { animation-delay: 0s; }
.panel-skeleton :global(.ps-r2) { animation-delay: 0.15s; }
.panel-skeleton :global(.ps-r3) { animation-delay: 0.3s; }
.panel-skeleton :global(.ps-r4) { animation-delay: 0.1s; }
.panel-skeleton :global(.ps-r5) { animation-delay: 0.25s; }
@keyframes ps-shimmer {
0% { stroke-dashoffset: 400; opacity: 0.25; }
40% { stroke-dashoffset: 0; opacity: 0.55; }
70% { stroke-dashoffset: 0; opacity: 0.55; }
100% { stroke-dashoffset: -400; opacity: 0.25; }
}
.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(var(--visual-vh, 100vh) - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
:global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(var(--visual-vh, 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); }
.midscroll-bar {
position: fixed;
top: 50%;
transform: translateY(-50%);
z-index: 200;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px 6px;
background: color-mix(in srgb, var(--bg-raised) 92%, transparent);
border: 1px solid var(--border-base);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.45);
pointer-events: auto;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.midscroll-bar-right { right: 8px; }
.midscroll-bar-left { left: 8px; }
.midscroll-segments {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.midscroll-origin-dot {
width: 6px;
height: 6px;
border-radius: 50%;
border: 1.5px solid var(--accent-fg);
opacity: 0.6;
flex-shrink: 0;
margin: 2px 0;
}
.midscroll-seg {
width: 4px;
height: 14px;
border-radius: 2px;
background: var(--border-strong);
transition: background 0.06s ease;
flex-shrink: 0;
}
.midscroll-seg-lit {
background: var(--accent-fg);
}
.midscroll-stop {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
background: none;
color: var(--text-faint);
cursor: pointer;
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
flex-shrink: 0;
}
.midscroll-stop:hover {
color: var(--text-primary);
background: var(--bg-overlay);
border-color: var(--border-strong);
}
</style>
@@ -0,0 +1,646 @@
<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, setMangaReaderSettings, clearMangaReaderSettings,
saveReaderPreset, updateReaderPreset, deleteReaderPreset } 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";
import ReaderPresetPanel from "./ReaderPresetPanel.svelte";
const win = getCurrentWindow();
const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") !== "NONE");
const effectiveReaderSettings = $derived.by(() => {
const mangaId = store.activeManga?.id;
const override = mangaId != null ? (store.settings.mangaReaderSettings ?? {})[mangaId] : undefined;
return override ? { ...store.settings, ...override } : store.settings;
});
const rtl = $derived(effectiveReaderSettings.readingDirection === "rtl");
const fit = $derived((effectiveReaderSettings.fitMode ?? "width") as FitMode);
const style = $derived((effectiveReaderSettings.pageStyle ?? "single") as typeof PAGE_STYLES[number]);
const zoom = $derived(effectiveReaderSettings.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 barPosition = $derived((store.settings.barPosition ?? "top") as "top" | "left" | "right");
const isVerticalBar = $derived(barPosition === "left" || barPosition === "right");
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 pinchZoomEnabled = $derived(store.settings.pinchZoom ?? false);
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",
effectiveReaderSettings.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);
const perMangaEnabled = $derived(
store.activeManga?.id != null &&
!!(store.settings.mangaReaderSettings ?? {})[store.activeManga.id]
);
let containerEl: HTMLDivElement | null = null;
let pageViewRef: PageView;
let zoomAnchor = { el: null as HTMLElement | null, offset: 0 };
let hideTimer = $state<ReturnType<typeof setTimeout> | null>(null);
let markedRead = new Set<number>();
let appending = false;
let abortCtrl = { current: null as AbortController | null };
let hasNavigated = false;
let startAtLastPageRef = { current: false };
let cleanupScroll: () => void = () => {};
let stripChaptersRef = readerState.stripChapters;
$effect(() => { stripChaptersRef = readerState.stripChapters; });
function maybeMarkCurrentRead() {
const ch = displayChapter ?? 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;
applySettings({ 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); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
openSettings: () => setSettingsOpen(true),
toggleBookmark: () => toggleBookmark(displayChapter, store.pageNumber),
toggleAutoScroll: () => { if (style === "longstrip") updateSettings({ autoScroll: !(store.settings.autoScroll ?? false) }); },
toggleMarker: () => {
if (currentPageMarkers.length > 0) {
const first = currentPageMarkers[0];
readerState.openMarker(first.id, first.note, first.color);
} else {
readerState.openMarker("", "", "yellow");
}
},
chapterNext: () => {
const ch = rtl ? adjacent.prev : adjacent.next;
if (ch) { maybeMarkCurrentRead(); 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; }
function captureCurrentReaderSettings() {
return {
pageStyle: style,
fitMode: fit,
readingDirection: (store.settings.readingDirection ?? "ltr") as import("@store/state.svelte").ReadingDirection,
readerZoom: zoom,
pageGap: effectiveReaderSettings.pageGap ?? true,
optimizeContrast: effectiveReaderSettings.optimizeContrast ?? false,
offsetDoubleSpreads: effectiveReaderSettings.offsetDoubleSpreads ?? false,
} satisfies import("@store/state.svelte").ReaderSettings;
}
function applySettings(patch: Parameters<typeof updateSettings>[0]) {
const mangaId = store.activeManga?.id;
if (mangaId != null && (store.settings.mangaReaderSettings ?? {})[mangaId]) {
setMangaReaderSettings(mangaId, { ...(store.settings.mangaReaderSettings ?? {})[mangaId]!, ...patch });
} else {
updateSettings(patch);
}
}
function handleTogglePerManga() {
const mangaId = store.activeManga?.id;
if (mangaId == null) return;
if ((store.settings.mangaReaderSettings ?? {})[mangaId]) {
clearMangaReaderSettings(mangaId);
} else {
setMangaReaderSettings(mangaId, captureCurrentReaderSettings());
}
}
function handleSavePreset(name: string) {
saveReaderPreset(name, captureCurrentReaderSettings());
}
function handleApplyPreset(settings: import("@store/state.svelte").ReaderSettings) {
const mangaId = store.activeManga?.id;
if (mangaId != null && (store.settings.mangaReaderSettings ?? {})[mangaId]) {
setMangaReaderSettings(mangaId, settings);
} else {
updateSettings(settings);
}
}
function handleBarPositionChange(pos: "top" | "left" | "right") {
updateSettings({ barPosition: pos });
}
$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, effectiveReaderSettings.offsetDoubleSpreads ?? false);
});
return () => { cancelled = true; };
} else { readerState.pageGroups = []; }
});
$effect(() => {
const ahead = store.settings.preloadPages ?? 3;
const current = store.pageUrls[store.pageNumber - 1];
const pageNum = store.pageNumber;
const urls = store.pageUrls;
if (!current) return;
const t = setTimeout(() => {
if (useBlob) {
import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => {
getBlobUrl(current, 999);
const upcoming = Array.from({ length: ahead }, (_, i) => urls[pageNum + i]).filter(Boolean) as string[];
const behind = urls[pageNum - 2];
preloadBlobUrls(upcoming, ahead);
if (behind) preloadBlobUrls([behind], 0);
});
} else {
for (let i = 1; i <= ahead; i++) {
const url = urls[pageNum - 1 + i];
if (url) preloadImage(url, useBlob);
}
const behind = urls[pageNum - 2];
if (behind) preloadImage(behind, useBlob);
}
}, 150);
return () => clearTimeout(t);
});
$effect(() => {
if (readerState.markerOpen || readerState.winOpen) {
readerState.uiVisible = true;
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
}
});
$effect(() => {
if (tapToToggleBar) {
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
readerState.uiVisible = true;
}
});
$effect(() => {
const ch = displayChapter ?? 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);
window.addEventListener("pointermove", pageViewRef.onPointerMove);
window.addEventListener("pointerup", pageViewRef.onPointerUp);
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);
window.removeEventListener("pointermove", pageViewRef.onPointerMove);
window.removeEventListener("pointerup", pageViewRef.onPointerUp);
cleanupScroll();
unlistenFs();
ro.disconnect();
};
});
</script>
<div
class="root"
class:overlay-bars={overlayBars}
class:bar-left={barPosition === "left"}
class:bar-right={barPosition === "right"}
class:pinch-active={pinchZoomEnabled}
role="presentation"
onmousemove={(e) => {
if (!tapToToggleBar) {
if (barPosition === "top" && (e.clientY < 60 || window.innerHeight - e.clientY < 60)) showUi();
if (barPosition === "left" && e.clientX < 60) showUi();
if (barPosition === "right" && window.innerWidth - e.clientX < 60) showUi();
}
}}
>
<ReaderControls
{displayChapter} {adjacent} {visibleChunkLastPage}
{zoom} {zoomPct}
isFullscreen={readerState.isFullscreen}
{isBookmarked} {hasMarkerOnPage} {currentPageMarkers}
uiVisible={readerState.uiVisible}
{hideTimer}
{barPosition}
progressBar={isVerticalBar ? progressBarSnippet : undefined}
onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)}
onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)}
onMaybeMarkRead={maybeMarkCurrentRead}
onToggleBookmark={() => toggleBookmark(displayChapter, store.pageNumber)}
onCommitMarker={commitMarker}
onDeleteMarker={deleteCurrentMarker}
onClampZoom={clampZoom}
onApplySettings={applySettings}
onDlOpen={() => readerState.dlOpen = true}
onSettingsOpen={() => setSettingsOpen(true)}
{perMangaEnabled}
{win}
/>
{#if readerState.presetOpen}
<ReaderPresetPanel
{fit} {style} {rtl} {zoom} {zoomPct}
{perMangaEnabled}
{barPosition}
onBarPositionChange={handleBarPositionChange}
onTogglePerManga={handleTogglePerManga}
onApplySettings={applySettings}
onSavePreset={handleSavePreset}
onApplyPreset={handleApplyPreset}
onUpdatePreset={updateReaderPreset}
onDeletePreset={deleteReaderPreset}
onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)}
onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)}
onClampZoom={clampZoom}
/>
{/if}
<ReaderOverlay
{showResumeBanner}
resumePage={readerState.resumePage}
resumeFading={readerState.resumeFading}
{adjacent}
onDismissResume={() => { readerState.resumeVisible = false; readerState.resumeFading = false; }}
/>
<PageView
bind:this={pageViewRef}
{style} {imgCls} {effectiveWidth}
loading={readerState.loading}
error={readerState.error}
pageReady={readerState.pageReady}
pageGroups={readerState.pageGroups}
{currentGroup} {stripToRender}
fadingOut={readerState.fadingOut}
{tapToToggleBar}
{pinchZoomEnabled}
{barPosition}
onGetZoom={() => zoom}
onSetZoom={(z) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: z }); restoreZoomAnchor(containerEl, zoomAnchor); }}
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
onTap={handleTap}
onWheel={handleWheel}
onToggleUi={toggleUiVisibility}
{bindContainer}
/>
{#snippet progressBarSnippet()}
<ReaderProgressBar
{style}
loading={readerState.loading}
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
uiVisible={readerState.uiVisible}
{barPosition}
onGoPrev={goPrev}
onGoNext={goNext}
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
/>
{/snippet}
{#if !isVerticalBar}
<ReaderProgressBar
{style}
loading={readerState.loading}
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
uiVisible={readerState.uiVisible}
{barPosition}
onGoPrev={goPrev}
onGoNext={goNext}
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
/>
{/if}
</div>
<style>
.root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform; }
.root.overlay-bars :global(.topbar) { position: absolute; top: 0; left: 0; right: 0; z-index: 10; }
.root.overlay-bars :global(.bottombar) { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; }
.root.overlay-bars :global(.viewer) { height: 100%; }
.root.bar-left :global(.viewer) { margin-left: 40px; }
.root.bar-right :global(.viewer) { margin-right: 40px; }
.root.pinch-active :global(.viewer) { touch-action: none; }
</style>
@@ -0,0 +1,625 @@
<script lang="ts">
import {
X, CaretLeft, CaretRight, CaretUp, CaretDown,
MagnifyingGlassMinus, MagnifyingGlassPlus,
Bookmark, MapPin, Download, Check, GearSix, Sliders,
} 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 } from "../store/readerState.svelte";
import { fly } from "svelte/transition";
import { cubicOut, cubicIn } from "svelte/easing";
import type { Chapter } from "@types";
import type { Snippet } from "svelte";
interface Props {
displayChapter: Chapter | null;
adjacent: { prev: Chapter | null; next: Chapter | null };
visibleChunkLastPage: number;
zoom: number;
zoomPct: number;
isFullscreen: boolean;
isBookmarked: boolean;
hasMarkerOnPage: boolean;
currentPageMarkers: { id: string; color: import("@store/state.svelte").MarkerColor; note: string }[];
uiVisible: boolean;
hideTimer: ReturnType<typeof setTimeout> | null;
barPosition: "top" | "left" | "right";
progressBar?: Snippet;
onCaptureZoomAnchor: () => void;
onRestoreZoomAnchor: () => void;
onMaybeMarkRead: () => void;
onToggleBookmark: () => void;
onCommitMarker: () => void;
onDeleteMarker: () => void;
onClampZoom: (z: number) => number;
onApplySettings: (patch: Parameters<typeof updateSettings>[0]) => void;
onDlOpen: () => void;
onSettingsOpen: () => void;
hasMangaOverride: boolean;
win: import("@tauri-apps/api/window").Window;
}
const {
displayChapter, adjacent, visibleChunkLastPage,
zoom, zoomPct, isFullscreen,
isBookmarked, hasMarkerOnPage, currentPageMarkers,
uiVisible, hideTimer,
barPosition, progressBar,
onCaptureZoomAnchor, onRestoreZoomAnchor,
onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker,
onClampZoom, onApplySettings, onDlOpen, onSettingsOpen,
hasMangaOverride, win,
}: Props = $props();
const isVertical = $derived(barPosition === "left" || barPosition === "right");
const popoverSide = $derived(
barPosition === "left" ? "right" :
barPosition === "right" ? "left" :
"bottom"
);
function adjustZoom(delta: number) {
onCaptureZoomAnchor();
onApplySettings({ readerZoom: onClampZoom(zoom + delta) });
onRestoreZoomAnchor();
}
function resetZoom() {
onCaptureZoomAnchor();
onApplySettings({ readerZoom: 1.0 });
onRestoreZoomAnchor();
}
function keepUiAlive() {
readerState.uiVisible = true;
if (hideTimer) clearTimeout(hideTimer);
}
let wcTimer: ReturnType<typeof setTimeout> | null = null;
function wcResetTimer() {
if (wcTimer) clearTimeout(wcTimer);
wcTimer = setTimeout(() => { readerState.winOpen = false; }, 1500);
}
$effect(() => {
if (readerState.winOpen) wcResetTimer();
else if (wcTimer) { clearTimeout(wcTimer); wcTimer = null; }
return () => { if (wcTimer) clearTimeout(wcTimer); };
});
function openMarkerPopover() {
if (currentPageMarkers.length > 0) {
const first = currentPageMarkers[0];
readerState.openMarker(first.id, first.note, first.color);
} else {
readerState.openMarker("", "", "yellow");
}
}
let chapterHover = $state(false);
let chapterHoverTimer: ReturnType<typeof setTimeout> | null = null;
function showChapterPopover() {
if (chapterHoverTimer) clearTimeout(chapterHoverTimer);
chapterHover = true;
}
function hideChapterPopover() {
chapterHoverTimer = setTimeout(() => { chapterHover = false; }, 120);
}
</script>
<div
class="bar"
class:bar-top={barPosition === "top"}
class:bar-left={barPosition === "left"}
class:bar-right={barPosition === "right"}
class:hidden={!uiVisible}
>
<div class="bar-start">
<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}>
{#if isVertical}
<CaretUp size={14} weight="light" />
{:else}
<CaretLeft size={14} weight="light" />
{/if}
</button>
<div
class="ch-hover-wrap"
onmouseenter={showChapterPopover}
onmouseleave={hideChapterPopover}
role="presentation"
>
<button class="ch-pill" title="{store.activeManga?.title} / {displayChapter?.name}">
{#if isVertical}
<span class="ch-info">&#xE2CE;</span>
{:else}
<span class="ch-marquee-track" onwheel={(e) => { e.stopPropagation(); (e.currentTarget as HTMLElement).scrollLeft += e.deltaY; }}>
<span class="ch-marquee-content">
<span class="ch-title">{store.activeManga?.title}</span>
<span class="ch-sep">/</span>
<span class="ch-name">{displayChapter?.name}</span>
</span>
</span>
{/if}
</button>
{#if !isVertical}
<span class="ch-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
{/if}
{#if chapterHover && isVertical}
<div class="ch-popover ch-popover-{popoverSide}">
<span class="ch-pop-title">{store.activeManga?.title}</span>
<span class="ch-pop-sep">/</span>
<span class="ch-pop-name">{displayChapter?.name}</span>
<span class="ch-pop-page">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
</div>
{/if}
</div>
<button class="icon-btn"
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, store.activeChapterList); } }}
disabled={!adjacent.next}>
{#if isVertical}
<CaretDown size={14} weight="light" />
{:else}
<CaretRight size={14} weight="light" />
{/if}
</button>
{#if !isVertical}
<span class="bar-sep" data-tauri-drag-region></span>
{/if}
</div>
{#if isVertical && progressBar}
<div class="bar-middle">
{@render progressBar()}
</div>
{/if}
{#if !isVertical}
<div class="bar-drag-gap" data-tauri-drag-region></div>
{/if}
<div class="bar-end">
<div class="zoom-wrap">
<div class="zoom-inline">
<button class="icon-btn zoom-icon-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
<MagnifyingGlassMinus size={13} weight="light" />
</button>
<div class="zoom-divider"></div>
<button class="zoom-pct-btn" onclick={() => readerState.zoomOpen = !readerState.zoomOpen} title="Click to adjust zoom">
{zoomPct}%
</button>
<div class="zoom-divider"></div>
<button class="icon-btn zoom-icon-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
<MagnifyingGlassPlus size={13} weight="light" />
</button>
</div>
{#if readerState.zoomOpen}
<div class="popover zoom-popover popover-{popoverSide}">
<div class="zoom-slider-row">
<input type="range" class="zoom-slider" min={10} max={100} step={5} value={zoomPct}
oninput={(e) => { onCaptureZoomAnchor(); onApplySettings({ readerZoom: onClampZoom(Number(e.currentTarget.value) / 100) }); onRestoreZoomAnchor(); }} />
</div>
<button class="zoom-reset" onclick={resetZoom} disabled={zoom === 1.0}>Reset</button>
</div>
{/if}
</div>
<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="popover marker-popover popover-{popoverSide}" 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>
<button class="icon-btn" onclick={onDlOpen}>
<Download size={14} weight="light" />
</button>
<button class="icon-btn" class:active={hasMangaOverride}
onclick={() => { readerState.presetOpen = true; readerState.markerOpen = false; readerState.zoomOpen = false; readerState.dlOpen = false; }}
title="Reader settings">
<Sliders size={13} weight="regular" />
</button>
<button class="icon-btn" onclick={onSettingsOpen} title="Settings">
<GearSix size={13} weight="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-clip wc-clip-{popoverSide}"
role="presentation"
onmouseenter={wcResetTimer}
onmousemove={wcResetTimer}
>
<div
class="wc-bar"
role="presentation"
onclick={(e) => e.stopPropagation()}
in:fly={isVertical
? (barPosition === "left" ? { x: '-100%', duration: 200, easing: cubicOut } : { x: '100%', duration: 200, easing: cubicOut })
: { y: '-100%', duration: 200, easing: cubicOut }}
out:fly={isVertical
? (barPosition === "left" ? { x: '-100%', duration: 150, easing: cubicIn } : { x: '100%', duration: 150, easing: cubicIn })
: { y: '-100%', duration: 150, easing: cubicIn }}
>
<button class="wc-icon-btn" onclick={() => { readerState.winOpen = false; win.minimize(); }} title="Minimize">
<svg width="10" height="2" viewBox="0 0 10 2"><line x1="0" y1="1" x2="10" y2="1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
<button class="wc-icon-btn" onclick={() => { readerState.winOpen = false; win.toggleMaximize(); }} title={isFullscreen ? "Exit Fullscreen" : "Fullscreen"}>
{#if isFullscreen}
<svg width="11" height="11" viewBox="0 0 11 11">
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="7,1 10,1 10,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="10,7 10,10 7,10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="4,10 1,10 1,7" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{:else}
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.75" y="0.75" width="8.5" height="8.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
{/if}
</button>
<button class="wc-icon-btn wc-icon-close" onclick={() => { readerState.winOpen = false; win.close(); }} title="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>
</button>
</div>
</div>
{/if}
</div>
</div>
</div>
<style>
.bar {
display: flex;
align-items: center;
gap: var(--sp-1);
background: var(--bg-void);
flex-shrink: 0;
position: relative;
z-index: 2;
transition: opacity 0.25s ease;
overflow: visible;
user-select: none;
}
.bar.hidden { opacity: 0; pointer-events: none; }
.bar-top {
flex-direction: row;
justify-content: space-between;
padding: 0 var(--sp-3);
height: 40px;
border-bottom: 1px solid var(--border-dim);
}
.bar-left, .bar-right {
flex-direction: column;
justify-content: space-between;
padding: var(--sp-3) 0;
width: 40px;
position: fixed;
top: 0;
bottom: 0;
z-index: 2;
border-bottom: none;
}
.bar-left { left: 0; border-right: 1px solid var(--border-dim); }
.bar-right { right: 0; border-left: 1px solid var(--border-dim); }
.bar-drag-gap { flex: 1; height: 100%; cursor: grab; }
.bar-drag-gap:active { cursor: grabbing; }
.bar-start, .bar-end {
display: flex;
align-items: center;
gap: var(--sp-1);
}
.bar-top .bar-start { overflow: hidden; }
.bar-left .bar-start,
.bar-left .bar-end,
.bar-right .bar-start,
.bar-right .bar-end {
flex-direction: column;
}
.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-hover-wrap { position: relative; min-width: 0; display: flex; align-items: center; gap: var(--sp-2); }
.ch-pill {
display: flex;
align-items: center;
font-size: var(--text-sm);
color: var(--text-muted);
overflow: hidden;
white-space: nowrap;
min-width: 0;
padding: 2px 4px;
border-radius: var(--radius-sm);
background: none;
cursor: default;
transition: background var(--t-fast);
}
.bar-left .ch-pill, .bar-right .ch-pill {
width: 28px;
height: 28px;
justify-content: center;
padding: 0;
}
.ch-info { font-size: 15px; line-height: 1; color: var(--text-faint); flex-shrink: 0; }
.ch-marquee-track {
overflow-x: auto;
min-width: 0;
flex: 1;
scrollbar-width: none;
}
.ch-marquee-track::-webkit-scrollbar { display: none; }
.ch-marquee-content {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
white-space: nowrap;
}
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
.ch-name { color: var(--text-muted); }
.ch-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.ch-popover {
position: absolute;
background: var(--bg-raised);
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
padding: var(--sp-2) var(--sp-3);
display: flex;
align-items: center;
gap: var(--sp-2);
white-space: nowrap;
z-index: 100;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
font-size: var(--text-sm);
pointer-events: none;
animation: scaleIn 0.1s ease both;
}
.ch-popover-right { left: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: left center; }
.ch-popover-left { right: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: right center; }
.ch-pop-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
.ch-pop-sep { color: var(--text-faint); }
.ch-pop-name { color: var(--text-muted); }
.ch-pop-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.bar-sep { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); }
.zoom-wrap { position: relative; flex-shrink: 0; }
.zoom-inline { display: flex; align-items: center; }
.bar-left .zoom-inline, .bar-right .zoom-inline { flex-direction: column; }
.zoom-icon-btn { width: 28px; height: 28px; }
.zoom-divider {
background: var(--border-dim);
flex-shrink: 0;
}
.bar-top .zoom-divider { width: 1px; height: 16px; }
.bar-left .zoom-divider,
.bar-right .zoom-divider { height: 1px; width: 16px; }
.zoom-pct-btn {
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
color: var(--text-secondary);
height: 28px;
min-width: 38px;
text-align: center;
transition: color var(--t-base), background var(--t-base);
padding: 0 var(--sp-1);
border-radius: 0;
}
.bar-left .zoom-pct-btn,
.bar-right .zoom-pct-btn { height: 24px; min-width: unset; width: 28px; writing-mode: vertical-rl; font-size: 9px; padding: var(--sp-1) 0; }
.zoom-pct-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
.popover {
position: absolute;
background: var(--bg-raised);
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
z-index: 100;
animation: scaleIn 0.1s ease both;
}
.popover-bottom { top: calc(100% + 6px); left: 50%; translate: -50% 0; transform-origin: top center; }
.popover-right { left: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: left center; }
.popover-left { right: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: right center; }
.zoom-popover { padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); min-width: 180px; }
.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 { width: 240px; padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); }
.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: static; flex-shrink: 0; }
.wc-clip {
position: absolute;
z-index: 100;
}
.wc-clip-bottom {
top: 100%;
right: var(--sp-3);
clip-path: inset(0 -20px -20px -20px);
}
.wc-clip-right {
left: calc(100% + 1px);
top: auto;
bottom: var(--sp-3);
clip-path: inset(-20px -20px -20px 0);
}
.wc-clip-left {
right: calc(100% + 1px);
top: auto;
bottom: var(--sp-3);
clip-path: inset(-20px 0 -20px -20px);
}
.wc-bar {
display: flex;
align-items: center;
gap: 1px;
padding: 3px 10px 4px;
background: var(--bg-raised);
border: 1px solid var(--border-base);
box-shadow: 0 6px 16px rgba(0,0,0,0.45);
}
.wc-clip-bottom .wc-bar { border-top: none; border-radius: 0 0 8px 8px; flex-direction: row; }
.wc-clip-right .wc-bar { border-left: none; border-radius: 0 8px 8px 0; flex-direction: column; padding: 10px 4px; }
.wc-clip-left .wc-bar { border-right: none; border-radius: 8px 0 0 8px; flex-direction: column; padding: 10px 4px; }
.wc-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 24px;
border-radius: var(--radius-sm);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
transition: color var(--t-base), background var(--t-base);
flex-shrink: 0;
}
.wc-icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
.wc-icon-close:hover { color: #fff; background: #c0392b; }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
.bar-middle {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
min-height: 0;
padding: var(--sp-1) 0;
overflow: hidden;
}
</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,813 @@
<script lang="ts">
import {
X, Check, Trash, FloppyDisk,
Square, Rows, BookOpen, MonitorPlay,
ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical,
ArrowsHorizontal,
SidebarSimple,
} from "phosphor-svelte";
import type { ReaderSettings, ReaderPreset, FitMode } from "@store/state.svelte";
import { store, updateSettings } from "@store/state.svelte";
import { readerState, PAGE_STYLES, ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte";
import { fade, fly } from "svelte/transition";
import { cubicOut } from "svelte/easing";
interface Props {
fit: FitMode;
style: string;
rtl: boolean;
zoom: number;
zoomPct: number;
perMangaEnabled: boolean;
onTogglePerManga: () => void;
onSavePreset: (name: string) => void;
onApplyPreset: (settings: ReaderSettings) => void;
onUpdatePreset: (id: string, patch: Partial<Pick<ReaderPreset, "name" | "settings">>) => void;
onDeletePreset: (id: string) => void;
onApplySettings: (patch: Partial<ReaderSettings>) => void;
onCaptureZoomAnchor: () => void;
onRestoreZoomAnchor: () => void;
onClampZoom: (z: number) => number;
barPosition: "top" | "left" | "right";
onBarPositionChange: (pos: "top" | "left" | "right") => void;
}
const {
fit, style, rtl, zoom, zoomPct,
perMangaEnabled, onTogglePerManga,
onSavePreset, onApplyPreset, onUpdatePreset, onDeletePreset,
onApplySettings,
onCaptureZoomAnchor, onRestoreZoomAnchor, onClampZoom,
barPosition, onBarPositionChange,
}: Props = $props();
const presets = $derived(store.settings.readerPresets ?? []);
const effectiveSettings = $derived.by(() => {
const mangaId = store.activeManga?.id;
const override = mangaId != null ? (store.settings.mangaReaderSettings ?? {})[mangaId] : undefined;
return override ? { ...store.settings, ...override } : store.settings;
});
let presetSaving = $state(false);
let presetNameInput = $state("");
let presetEditId = $state<string | null>(null);
let presetEditName = $state("");
function close() {
readerState.presetOpen = false;
presetSaving = false;
presetNameInput = "";
presetEditId = null;
}
function commitSavePreset() {
if (!presetNameInput.trim()) return;
onSavePreset(presetNameInput.trim());
presetSaving = false;
presetNameInput = "";
}
function commitRenamePreset() {
if (!presetEditId || !presetEditName.trim()) return;
onUpdatePreset(presetEditId, { name: presetEditName.trim() });
presetEditId = null;
presetEditName = "";
}
function describeSettings(s: ReaderSettings): string {
const parts = [s.pageStyle ?? "single", s.fitMode ?? "width", (s.readingDirection ?? "ltr") === "rtl" ? "RTL" : "LTR"];
if ((s.readerZoom ?? 1) !== 1.0) parts.push(`${Math.round((s.readerZoom ?? 1) * 100)}%`);
if (!s.pageGap) parts.push("no gap");
return parts.join(" · ");
}
function setZoom(v: number) {
onCaptureZoomAnchor();
onApplySettings({ readerZoom: onClampZoom(v) });
onRestoreZoomAnchor();
}
const fitOptions: { value: FitMode; label: string; icon: any }[] = [
{ value: "width", label: "Fit Width", icon: ArrowsLeftRight },
{ value: "height", label: "Fit Height", icon: ArrowsVertical },
{ value: "screen", label: "Fit Screen", icon: ArrowsIn },
{ value: "original", label: "Original", icon: ArrowsOut },
];
const styleOptions: { value: string; label: string; icon: any }[] = [
{ value: "single", label: "Single", icon: Square },
{ value: "double", label: "Double", icon: BookOpen },
{ value: "fade", label: "Fade", icon: MonitorPlay },
{ value: "longstrip", label: "Long Strip", icon: Rows },
];
const barOptions: { value: "top" | "left" | "right"; label: string }[] = [
{ value: "left", label: "Left" },
{ value: "top", label: "Top" },
{ value: "right", label: "Right" },
];
</script>
<div class="backdrop" role="button" tabindex="-1" aria-label="Close settings" onclick={close} onkeydown={(e) => e.key === 'Escape' && close()} transition:fade={{ duration: 150 }}></div>
<div
class="panel"
role="dialog"
aria-label="Reader settings & presets"
transition:fly={{ x: 320, duration: 220, easing: cubicOut }}
>
<div class="panel-header">
<span class="panel-title">Reader Settings</span>
{#if store.activeManga}
<span class="panel-manga">{store.activeManga.title}</span>
{/if}
<button class="close-btn" onclick={close}><X size={14} weight="light" /></button>
</div>
<div class="panel-body">
<section class="section">
<p class="section-label">Page Style</p>
<div class="option-grid">
{#each styleOptions as o}
{@const Icon = o.icon}
<button
class="option-tile"
class:active={style === o.value}
onclick={() => onApplySettings({ pageStyle: o.value as typeof PAGE_STYLES[number] })}
>
<div class="tile-icon"><Icon size={18} weight={style === o.value ? "fill" : "light"} /></div>
<span class="tile-label">{o.label}</span>
</button>
{/each}
</div>
{#if style === "double"}
<label class="toggle-row">
<span class="toggle-label">Offset double spreads</span>
<button
class="toggle"
class:on={effectiveSettings.offsetDoubleSpreads}
onclick={() => onApplySettings({ offsetDoubleSpreads: !effectiveSettings.offsetDoubleSpreads })}
role="switch"
aria-label="Offset double spreads"
aria-checked={effectiveSettings.offsetDoubleSpreads}
><span class="toggle-knob"></span></button>
</label>
{/if}
{#if style === "longstrip"}
<label class="toggle-row">
<span class="toggle-label">Gap between pages</span>
<button
class="toggle"
class:on={effectiveSettings.pageGap ?? true}
onclick={() => onApplySettings({ pageGap: !(effectiveSettings.pageGap ?? true) })}
role="switch"
aria-label="Gap between pages"
aria-checked={effectiveSettings.pageGap ?? true}
><span class="toggle-knob"></span></button>
</label>
<label class="toggle-row">
<span class="toggle-label">Auto next chapter</span>
<button
class="toggle"
class:on={store.settings.autoNextChapter ?? false}
onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })}
role="switch"
aria-label="Auto next chapter"
aria-checked={store.settings.autoNextChapter ?? false}
><span class="toggle-knob"></span></button>
</label>
<label class="toggle-row">
<span class="toggle-label">Auto scroll</span>
<button
class="toggle"
class:on={store.settings.autoScroll ?? false}
onclick={() => updateSettings({ autoScroll: !(store.settings.autoScroll ?? false) })}
role="switch"
aria-label="Auto scroll"
aria-checked={store.settings.autoScroll ?? false}
><span class="toggle-knob"></span></button>
</label>
{#if store.settings.autoScroll}
<div class="speed-row">
<span class="speed-label">Speed</span>
<input
type="range"
class="zoom-slider"
min={1}
max={30}
step={1}
value={store.settings.autoScrollSpeed ?? 5}
oninput={(e) => updateSettings({ autoScrollSpeed: Number(e.currentTarget.value) })}
/>
<span class="speed-val">{store.settings.autoScrollSpeed ?? 5}</span>
</div>
{/if}
{/if}
</section>
<section class="section">
<p class="section-label">Fit Mode</p>
<div class="option-grid">
{#each fitOptions as o}
{@const Icon = o.icon}
<button
class="option-tile"
class:active={fit === o.value}
onclick={() => onApplySettings({ fitMode: o.value })}
>
<div class="tile-icon"><Icon size={18} weight={fit === o.value ? "fill" : "light"} /></div>
<span class="tile-label">{o.label}</span>
</button>
{/each}
</div>
</section>
<section class="section">
<p class="section-label">Reading Direction</p>
<div class="dir-row">
<button
class="dir-btn"
class:active={!rtl}
onclick={() => onApplySettings({ readingDirection: "ltr" })}
>
<ArrowsHorizontal size={14} weight="light" />
<span>Left to Right</span>
</button>
<button
class="dir-btn"
class:active={rtl}
onclick={() => onApplySettings({ readingDirection: "rtl" })}
>
<ArrowsHorizontal size={14} weight="light" style="transform:scaleX(-1)" />
<span>Right to Left</span>
</button>
</div>
</section>
<section class="section">
<p class="section-label">Bar Position</p>
<div class="bar-grid">
{#each barOptions as o}
<button
class="bar-tile"
class:active={barPosition === o.value}
onclick={() => onBarPositionChange(o.value)}
>
<div class="bar-tile-preview bar-preview-{o.value}">
<div class="bar-preview-strip"></div>
<div class="bar-preview-content"></div>
</div>
<span class="tile-label">{o.label}</span>
</button>
{/each}
</div>
</section>
<section class="section">
<div class="section-header-row">
<p class="section-label" style="margin:0">Zoom</p>
<span class="zoom-readout">{zoomPct}%</span>
</div>
<div class="zoom-row">
<button class="zoom-step" aria-label="Zoom out" onclick={() => setZoom(zoom - 0.1)} disabled={zoom <= ZOOM_MIN}></button>
<input
type="range"
class="zoom-slider"
min={Math.round(ZOOM_MIN * 100)}
max={Math.round(ZOOM_MAX * 100)}
step={5}
value={zoomPct}
oninput={(e) => setZoom(Number(e.currentTarget.value) / 100)}
/>
<button class="zoom-step" aria-label="Zoom in" onclick={() => setZoom(zoom + 0.1)} disabled={zoom >= ZOOM_MAX}>+</button>
</div>
</section>
<section class="section">
<p class="section-label">Image</p>
<label class="toggle-row">
<span class="toggle-label">Optimize contrast</span>
<button
class="toggle"
class:on={effectiveSettings.optimizeContrast}
onclick={() => onApplySettings({ optimizeContrast: !effectiveSettings.optimizeContrast })}
role="switch"
aria-label="Optimize contrast"
aria-checked={effectiveSettings.optimizeContrast}
><span class="toggle-knob"></span></button>
</label>
<label class="toggle-row">
<span class="toggle-label">Pinch to zoom <span class="toggle-badge">experimental</span></span>
<button
class="toggle"
class:on={store.settings.pinchZoom ?? false}
onclick={() => updateSettings({ pinchZoom: !(store.settings.pinchZoom ?? false) })}
role="switch"
aria-label="Pinch to zoom"
aria-checked={store.settings.pinchZoom ?? false}
><span class="toggle-knob"></span></button>
</label>
<label class="toggle-row">
<span class="toggle-label">Mark read on chapter advance</span>
<button
class="toggle"
class:on={store.settings.markReadOnNext ?? true}
onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })}
role="switch"
aria-label="Mark read on chapter advance"
aria-checked={store.settings.markReadOnNext ?? true}
><span class="toggle-knob"></span></button>
</label>
</section>
{#if store.activeManga}
<section class="section">
<label class="toggle-row">
<span class="toggle-label">Per-manga settings</span>
<button
class="toggle"
class:on={perMangaEnabled}
onclick={onTogglePerManga}
role="switch"
aria-label="Per-manga settings"
aria-checked={perMangaEnabled}
><span class="toggle-knob"></span></button>
</label>
</section>
{/if}
<section class="section">
<div class="section-header-row">
<p class="section-label" style="margin:0">Saved Presets</p>
{#if !presetSaving}
<button class="new-preset-btn" onclick={() => { presetSaving = true; presetNameInput = ""; }}>+ New</button>
{/if}
</div>
{#if presetSaving}
<div class="preset-name-row">
<input
class="preset-name-input"
placeholder="Preset name…"
bind:value={presetNameInput}
onkeydown={(e) => { if (e.key === "Enter") commitSavePreset(); if (e.key === "Escape") presetSaving = false; }}
/>
<button class="small-btn" aria-label="Confirm" disabled={!presetNameInput.trim()} onclick={commitSavePreset}><Check size={12} weight="bold" /></button>
<button class="small-btn" aria-label="Cancel" onclick={() => presetSaving = false}><X size={12} weight="light" /></button>
</div>
{/if}
{#if presets.length === 0 && !presetSaving}
<p class="empty-hint">No presets saved yet. Save the current settings to create one.</p>
{:else}
<div class="preset-list">
{#each presets as p (p.id)}
{#if presetEditId === p.id}
<div class="preset-name-row">
<input
class="preset-name-input"
bind:value={presetEditName}
onkeydown={(e) => { if (e.key === "Enter") commitRenamePreset(); if (e.key === "Escape") presetEditId = null; }}
/>
<button class="small-btn" aria-label="Confirm" disabled={!presetEditName.trim()} onclick={commitRenamePreset}><Check size={12} weight="bold" /></button>
<button class="small-btn" aria-label="Cancel" onclick={() => presetEditId = null}><X size={12} weight="light" /></button>
</div>
{:else}
<div class="preset-row">
<button class="preset-apply" onclick={() => { onApplyPreset(p.settings); close(); }}>
<span class="preset-name">{p.name}</span>
<span class="preset-desc">{describeSettings(p.settings)}</span>
</button>
<button class="small-btn" title="Rename" onclick={() => { presetEditId = p.id; presetEditName = p.name; }}>
<FloppyDisk size={12} weight="regular" />
</button>
<button class="small-btn danger" title="Delete" onclick={() => onDeletePreset(p.id)}>
<Trash size={12} weight="regular" />
</button>
</div>
{/if}
{/each}
</div>
{/if}
</section>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: calc(var(--z-reader) + 20);
background: rgba(0, 0, 0, 0.35);
}
.panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 320px;
z-index: calc(var(--z-reader) + 21);
background: var(--bg-surface);
border-left: 1px solid var(--border-base);
display: flex;
flex-direction: column;
box-shadow: -12px 0 40px rgba(0, 0, 0, 0.5);
}
.panel-header {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 0 var(--sp-4);
height: 48px;
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.panel-title {
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--text-primary);
letter-spacing: var(--tracking-tight);
}
.panel-manga {
flex: 1;
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
}
.close-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);
}
.close-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
.panel-body {
flex: 1;
overflow-y: auto;
padding: var(--sp-3) var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-4);
scrollbar-width: thin;
scrollbar-color: var(--border-dim) transparent;
}
.section { display: flex; flex-direction: column; gap: var(--sp-2); }
.section-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
margin: 0 0 var(--sp-1);
}
.section-header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-1);
}
.option-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-1);
}
.option-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: var(--sp-2) var(--sp-1);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
cursor: pointer;
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.option-tile:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
.option-tile.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.tile-icon { display: flex; align-items: center; justify-content: center; }
.tile-label { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: capitalize; line-height: 1; }
.bar-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--sp-1);
}
.bar-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: var(--sp-2) var(--sp-1);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
cursor: pointer;
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.bar-tile:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
.bar-tile.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.bar-tile-preview {
width: 32px;
height: 22px;
border-radius: 3px;
border: 1px solid currentColor;
position: relative;
overflow: hidden;
opacity: 0.7;
display: flex;
}
.bar-tile.active .bar-tile-preview { opacity: 1; }
.bar-preview-strip {
background: currentColor;
opacity: 0.5;
flex-shrink: 0;
}
.bar-preview-content {
flex: 1;
background: color-mix(in srgb, currentColor 8%, transparent);
}
.bar-preview-top { flex-direction: column; }
.bar-preview-left { flex-direction: row; }
.bar-preview-right { flex-direction: row-reverse; }
.bar-preview-top .bar-preview-strip { height: 5px; width: 100%; }
.bar-preview-left .bar-preview-strip { width: 5px; height: 100%; }
.bar-preview-right .bar-preview-strip { width: 5px; height: 100%; }
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-1) 0;
}
.toggle-label {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.toggle-badge {
font-family: var(--font-ui);
font-size: 9px;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--text-faint);
border: 1px solid var(--border-dim);
border-radius: 3px;
padding: 1px 4px;
margin-left: var(--sp-1);
vertical-align: middle;
}
.toggle {
position: relative;
width: 32px;
height: 18px;
border-radius: 9px;
background: var(--border-strong);
border: none;
cursor: pointer;
flex-shrink: 0;
transition: background var(--t-base);
}
.toggle.on { background: var(--accent-fg); }
.toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
transition: left var(--t-base);
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.toggle.on .toggle-knob { left: 16px; }
.dir-row { display: flex; gap: var(--sp-2); }
.dir-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
font-size: var(--text-xs);
cursor: pointer;
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.dir-btn:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
.dir-btn.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.zoom-readout {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-secondary);
letter-spacing: var(--tracking-wide);
}
.zoom-row {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.zoom-step {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
font-size: var(--text-base);
line-height: 1;
flex-shrink: 0;
transition: color var(--t-fast), background var(--t-fast);
}
.zoom-step:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.zoom-step:disabled { opacity: 0.25; cursor: default; }
.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: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent-fg);
cursor: pointer;
box-shadow: 0 0 0 2px rgba(0,0,0,0.3);
}
.new-preset-btn {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--accent-fg);
letter-spacing: var(--tracking-wide);
background: none;
border: none;
cursor: pointer;
padding: 2px var(--sp-1);
border-radius: var(--radius-sm);
transition: background var(--t-fast);
}
.new-preset-btn:hover { background: var(--accent-muted); }
.preset-name-row { display: flex; align-items: center; gap: var(--sp-1); }
.preset-name-input {
flex: 1;
background: var(--bg-raised);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 5px 8px;
font-size: var(--text-xs);
color: var(--text-primary);
outline: none;
font-family: inherit;
transition: border-color var(--t-base);
}
.preset-name-input:focus { border-color: var(--accent-dim); }
.preset-list { display: flex; flex-direction: column; gap: 2px; }
.preset-row { display: flex; align-items: center; gap: var(--sp-1); }
.preset-apply {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 7px var(--sp-2);
border-radius: var(--radius-md);
background: none;
border: none;
cursor: pointer;
text-align: left;
transition: background var(--t-fast);
min-width: 0;
}
.preset-apply:hover { background: var(--bg-overlay); }
.preset-name {
font-size: var(--text-xs);
color: var(--text-secondary);
font-weight: var(--weight-medium);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.preset-desc {
font-family: var(--font-ui);
font-size: 10px;
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.small-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: var(--radius-sm);
border: none;
background: none;
color: var(--text-faint);
cursor: pointer;
flex-shrink: 0;
transition: color var(--t-fast), background var(--t-fast);
}
.small-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); }
.small-btn:disabled { opacity: 0.25; cursor: default; }
.small-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
.empty-hint {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
margin: 0;
padding: var(--sp-2) 0;
text-align: center;
}
.speed-row {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-1) 0;
}
.speed-label {
font-size: var(--text-xs);
color: var(--text-faint);
flex-shrink: 0;
min-width: 40px;
}
.speed-val {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-secondary);
letter-spacing: var(--tracking-wide);
min-width: 1.5ch;
text-align: right;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,258 @@
<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;
barPosition: "top" | "left" | "right";
onGoPrev: () => void;
onGoNext: () => void;
onJumpToPage: (page: number) => void;
}
const {
style, loading, rtl, sliderPage, sliderMax, sliderPct, lastPage,
displayChapter, currentBookmark, activeChapterMarkers, adjacent, uiVisible,
barPosition,
onGoPrev, onGoNext, onJumpToPage,
}: Props = $props();
const isVertical = $derived(barPosition === "left" || barPosition === "right");
const hValue = $derived(rtl ? sliderMax - sliderPage + 1 : sliderPage);
const hPct = $derived(`--pct:${sliderPct}%`);
const vPct = $derived(`--pct:${sliderPct}%`);
function handleH(e: Event) {
const raw = Number((e.target as HTMLInputElement).value);
onJumpToPage(rtl ? sliderMax - raw + 1 : raw);
}
function handleV(e: Event) {
onJumpToPage(Number((e.target as HTMLInputElement).value));
}
function markerPct(pageNumber: number, forRtl = false): number {
if (sliderMax <= 1) return 0;
const ord = forRtl ? sliderMax - pageNumber + 1 : pageNumber;
return ((ord - 1) / (sliderMax - 1)) * 100;
}
</script>
{#if !isVertical}
<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"
onmouseenter={() => readerState.sliderHover = true}
onmouseleave={() => readerState.sliderHover = false}
>
<input
type="range"
class="h-range"
style={hPct}
min={1}
max={sliderMax}
value={hValue}
oninput={handleH}
onmousedown={() => readerState.sliderDragging = true}
onmouseup={() => readerState.sliderDragging = false}
/>
<div class="slider-markers" aria-hidden="true">
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
<div class="slider-checkpoint bookmark-checkpoint"
style="left:{markerPct(currentBookmark.pageNumber, rtl)}%"
title="Bookmark: Page {currentBookmark.pageNumber}">
</div>
{/if}
{#each activeChapterMarkers as m (m.id)}
<div class="slider-checkpoint marker-checkpoint"
style="left:{markerPct(m.pageNumber, rtl)}%;background:{MARKER_COLOR_HEX[m.color]}"
title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}">
</div>
{/each}
</div>
{#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>
{:else}
<div class="vbar-progress" class:hidden={!uiVisible} class:vbar-right={barPosition === "right"}>
{#if sliderMax > 1}
<div
class="vslider-wrap"
onmouseenter={() => readerState.sliderHover = true}
onmouseleave={() => readerState.sliderHover = false}
>
<input
type="range"
class="v-range"
style={vPct}
min={1}
max={sliderMax}
value={sliderPage}
oninput={handleV}
onmousedown={() => readerState.sliderDragging = true}
onmouseup={() => readerState.sliderDragging = false}
/>
<div class="vslider-markers" aria-hidden="true">
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
{@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
<div class="vslider-checkpoint bookmark-checkpoint"
style="top:{bPct}%"
title="Bookmark: Page {currentBookmark.pageNumber}">
</div>
{/if}
{#each activeChapterMarkers as m (m.id)}
{@const mPct = sliderMax > 1 ? ((m.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
<div class="vslider-checkpoint marker-checkpoint"
style="top:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}"
title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}">
</div>
{/each}
</div>
{#if readerState.sliderHover || readerState.sliderDragging}
<div class="vslider-tooltip" style="top:{sliderPct}%" class:tooltip-right={barPosition === "right"}>
{sliderPage} / {sliderMax}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<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; }
.h-range {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 34px;
background: transparent;
cursor: pointer;
position: relative;
z-index: 2;
margin: 0;
padding: 0;
}
.h-range::-webkit-slider-runnable-track {
height: 3px;
background: linear-gradient(to right, var(--accent-fg) var(--pct, 0%), var(--border-strong) var(--pct, 0%));
border-radius: 2px;
transition: height 0.15s ease, background 0.05s linear;
}
.h-range:hover::-webkit-slider-runnable-track,
.h-range:active::-webkit-slider-runnable-track { height: 5px; }
.h-range::-moz-range-track { height: 3px; background: var(--border-strong); border-radius: 2px; transition: height 0.15s ease; }
.h-range::-moz-range-progress { height: 3px; background: var(--accent-fg); border-radius: 2px; transition: height 0.15s ease; }
.h-range:hover::-moz-range-track, .h-range:active::-moz-range-track { height: 5px; }
.h-range:hover::-moz-range-progress, .h-range:active::-moz-range-progress { height: 5px; }
.h-range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent-fg);
box-shadow: 0 0 0 2px rgba(0,0,0,0.5);
margin-top: -4.5px;
transition: transform var(--t-fast);
}
.h-range:hover::-webkit-slider-thumb,
.h-range:active::-webkit-slider-thumb { transform: scale(1.3); }
.h-range::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent-fg);
box-shadow: 0 0 0 2px rgba(0,0,0,0.5);
border: none;
transition: transform var(--t-fast);
}
.h-range:hover::-moz-range-thumb,
.h-range:active::-moz-range-thumb { transform: scale(1.3); }
.slider-markers { position: absolute; inset: 0; pointer-events: none; z-index: 1; }
.slider-checkpoint { position: absolute; top: 50%; width: 4px; height: 10px; border-radius: 2px; transform: translate(-50%, -50%); }
.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); }
.vbar-progress { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; width: 100%; padding: var(--sp-2) 0; transition: opacity 0.25s ease; pointer-events: none; }
.vbar-progress.hidden { opacity: 0; }
.vslider-wrap { flex: 1; position: relative; display: flex; flex-direction: column; align-items: center; width: 36px; pointer-events: all; margin: var(--sp-1) 0; }
.v-range {
-webkit-appearance: slider-vertical;
appearance: slider-vertical;
writing-mode: vertical-lr;
direction: rtl;
width: 34px;
height: 100%;
background: transparent;
cursor: pointer;
position: relative;
z-index: 2;
margin: 0;
padding: 0;
}
.v-range::-webkit-slider-runnable-track { width: 5px; background: linear-gradient(to bottom, var(--accent-fg) var(--pct, 0%), var(--border-strong) var(--pct, 0%)); border-radius: 3px; transition: width 0.15s ease, background 0.05s linear; }
.v-range:hover::-webkit-slider-runnable-track,
.v-range:active::-webkit-slider-runnable-track { width: 7px; }
.v-range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent-fg);
box-shadow: 0 0 0 2px rgba(0,0,0,0.5);
margin-left: -4.5px;
transition: transform var(--t-fast);
}
.v-range:hover::-webkit-slider-thumb,
.v-range:active::-webkit-slider-thumb { transform: scale(1.3); }
.vslider-markers { position: absolute; inset: 0; pointer-events: none; }
.vslider-checkpoint { position: absolute; left: 50%; transform: translate(-50%, -50%); width: 12px; height: 5px; border-radius: 2px; }
.vslider-tooltip { position: absolute; left: calc(100% + 6px); transform: translateY(-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); }
.vslider-tooltip.tooltip-right { left: auto; right: calc(100% + 6px); }
</style>
View File
@@ -0,0 +1,79 @@
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";
import { trackingState } from "@features/tracking/store/trackingState.svelte";
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 ch = store.activeChapterList.find(c => c.id === id) ?? store.activeChapter;
const prefs = getMangaPrefs();
if (ch) trackingState.updateFromRead(mangaId, ch, store.activeChapterList, prefs);
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 });
}
}
+58
View File
@@ -0,0 +1,58 @@
import { store } from "@store/state.svelte";
import { readerState } from "../store/readerState.svelte";
import { fetchPages } from "./pageLoader";
import { trackingState } from "@features/tracking/store/trackingState.svelte";
import { cancelQueuedFetches } from "@core/cache/imageCache";
import { clearResolvedUrlCache } from "@core/cache/pageCache";
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;
cancelQueuedFetches();
if (useBlob) clearResolvedUrlCache();
startAtLastPage.current = false;
markedRead.clear();
readerState.resetForChapter();
store.pageUrls = [];
const mangaId = store.activeManga?.id;
if (mangaId) trackingState.loadForManga(mangaId);
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, ctrl.signal).catch(() => {});
} catch (e: any) {
if (ctrl.signal.aborted) return;
readerState.error = e instanceof Error ? e.message : String(e);
readerState.loading = false;
}
}
+14
View File
@@ -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";
+84
View File
@@ -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));
}
}
+13
View File
@@ -0,0 +1,13 @@
export { fetchPages, resolveUrl, preloadImage, measureAspect, clearPageCache } from "@core/cache/pageCache";
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;
}
+43
View File
@@ -0,0 +1,43 @@
import { createPinchGesture } from "@core/ui/touchscreen";
import { clampZoom } from "./zoomHelpers";
import { ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte";
export interface PinchTrackerOptions {
getZoom: () => number;
setZoom: (z: number) => void;
getInspectScale: () => number;
setInspectScale: (s: number) => void;
resetInspectPan: () => void;
isLongstrip: () => boolean;
}
export type { PinchGesture as PinchTracker } from "@core/ui/touchscreen";
const INSPECT_ZOOM_MAX = 8;
export function createPinchTracker(opts: PinchTrackerOptions) {
let startZoom = 0;
let startInspect = 0;
return createPinchGesture({
onPinch(scale) {
if (startZoom === 0) {
startZoom = opts.getZoom();
startInspect = opts.getInspectScale();
}
if (opts.isLongstrip()) {
opts.setZoom(clampZoom(startZoom * scale));
} else {
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, startInspect * scale));
if (next !== opts.getInspectScale()) {
if (next === 1) opts.resetInspectPan();
opts.setInspectScale(next);
}
}
},
onPinchEnd() {
startZoom = 0;
startInspect = 0;
},
});
}
@@ -0,0 +1,61 @@
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;
toggleAutoScroll: () => 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(); }
else if (matchesKeybind(e, kb.toggleAutoScroll)) { e.preventDefault(); actions.toggleAutoScroll(); }
};
}
+111
View File
@@ -0,0 +1,111 @@
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;
let rafId: number | null = null;
function tick() {
rafId = null;
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 lo = 0, hi = imgs.length - 1, best = 0;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if (imgs[mid].getBoundingClientRect().top <= readLineY) { best = mid; lo = mid + 1; }
else hi = mid - 1;
}
const active = imgs[best];
const activePage = Number(active.dataset.localPage);
const activeChId = Number(active.dataset.chapter);
onPageChange(activePage);
if (activeChId) onChapterChange(activeChId);
if (shouldAutoMark() && 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) {
const last = chunks[chunks.length - 1];
if (last) onMarkRead(last.chapterId);
}
}
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
if (pct >= 0.80) onAppend();
}
function onScroll() {
if (rafId !== null) return;
rafId = requestAnimationFrame(tick);
}
containerEl.addEventListener("scroll", onScroll, { passive: true });
return () => {
containerEl.removeEventListener("scroll", onScroll);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}
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());
}
+8
View File
@@ -0,0 +1,8 @@
import { clampZoom as _clampZoom, captureZoomAnchor, restoreZoomAnchor } from "@core/ui/zoom";
import { ZOOM_MIN, ZOOM_MAX } from "../store/readerState.svelte";
export { captureZoomAnchor, restoreZoomAnchor };
export function clampZoom(z: number): number {
return _clampZoom(z, ZOOM_MIN, ZOOM_MAX);
}
@@ -0,0 +1,110 @@
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);
presetOpen = $state(false);
presetNameInput = $state("");
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; }
if (this.presetOpen) { this.presetOpen = 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();