diff --git a/src/core/cache/index.ts b/src/core/cache/index.ts index 92ee52c..8b3d3fe 100644 --- a/src/core/cache/index.ts +++ b/src/core/cache/index.ts @@ -1,3 +1,4 @@ export * from './memoryCache'; +export * from './pageCache'; export * from './imageCache'; export * from './queryCache'; diff --git a/src/core/cache/pageCache.ts b/src/core/cache/pageCache.ts new file mode 100644 index 0000000..d10e3af --- /dev/null +++ b/src/core/cache/pageCache.ts @@ -0,0 +1,79 @@ +import { gql, plainThumbUrl } from "@api/client"; +import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache"; +import { dedupeRequest } from "@core/async/batchRequests"; +import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters"; + +const pageCache = new Map(); +const inflight = new Map>(); +const resolvedUrlCache = new Map>(); +const preloadedUrls = new Set(); +const aspectCache = new Map(); + +export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise { + if (!useBlob) return Promise.resolve(url); + if (!resolvedUrlCache.has(url)) resolvedUrlCache.set(url, getBlobUrl(url, priority)); + return resolvedUrlCache.get(url)!; +} + +export function fetchPages( + chapterId: number, + useBlob: boolean, + signal?: AbortSignal, + priorityPage = 0, +): Promise { + const cached = pageCache.get(chapterId); + if (cached) return Promise.resolve(cached); + if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")); + + if (!inflight.has(chapterId)) { + const p = dedupeRequest(`chapter-pages:${chapterId}`, () => + gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId }) + .then(d => { + const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p)); + if (useBlob) { + if (urls[priorityPage]) getBlobUrl(urls[priorityPage], urls.length + 999); + preloadBlobUrls(urls.filter((_, i) => i !== priorityPage), urls.length); + } + pageCache.set(chapterId, urls); + return urls; + }) + ).finally(() => inflight.delete(chapterId)); + inflight.set(chapterId, p); + } + + const base = inflight.get(chapterId)!; + if (!signal) return base; + return new Promise((resolve, reject) => { + signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + base.then(resolve, reject); + }); +} + +export function measureAspect(url: string, useBlob: boolean): Promise { + if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!); + return resolveUrl(url, useBlob).then(src => new Promise(res => { + const img = new Image(); + img.onload = () => { const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; aspectCache.set(url, r); res(r); }; + img.onerror = () => res(0.67); + img.src = src; + })); +} + +export function preloadImage(url: string, useBlob: boolean): void { + if (preloadedUrls.has(url)) return; + preloadedUrls.add(url); + resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {}); +} + +export function clearPageCache(chapterId?: number): void { + if (chapterId !== undefined) { + pageCache.delete(chapterId); + inflight.delete(chapterId); + } else { + pageCache.clear(); + inflight.clear(); + resolvedUrlCache.clear(); + preloadedUrls.clear(); + aspectCache.clear(); + } +} \ No newline at end of file diff --git a/src/core/ui/zoom.ts b/src/core/ui/zoom.ts index 700a072..891784a 100644 --- a/src/core/ui/zoom.ts +++ b/src/core/ui/zoom.ts @@ -7,11 +7,9 @@ export function applyZoom() { const uiZoom = store.settings.uiZoom ?? 1.0; if (uiZoom === _appliedZoom) return; _appliedZoom = uiZoom; - document.documentElement.style.setProperty("--ui-zoom", String(uiZoom)); document.documentElement.style.setProperty("--ui-scale", String(uiZoom)); document.documentElement.style.zoom = `${uiZoom * 100}%`; - if (_vhRafId !== null) cancelAnimationFrame(_vhRafId); _vhRafId = requestAnimationFrame(() => { _vhRafId = null; @@ -22,19 +20,42 @@ export function applyZoom() { export function handleZoomKey(e: KeyboardEvent) { if (!e.ctrlKey) return; const current = store.settings.uiZoom ?? 1.0; - if (e.key === "=" || e.key === "+") { - e.preventDefault(); - store.settings.uiZoom = Math.min(2.0, Math.round((current + 0.1) * 10) / 10); - } else if (e.key === "-") { - e.preventDefault(); - store.settings.uiZoom = Math.max(0.5, Math.round((current - 0.1) * 10) / 10); - } else if (e.key === "0") { - e.preventDefault(); - store.settings.uiZoom = 1.0; - } + if (e.key === "=" || e.key === "+") { e.preventDefault(); store.settings.uiZoom = Math.min(2.0, Math.round((current + 0.1) * 10) / 10); } + else if (e.key === "-") { e.preventDefault(); store.settings.uiZoom = Math.max(0.5, Math.round((current - 0.1) * 10) / 10); } + else if (e.key === "0") { e.preventDefault(); store.settings.uiZoom = 1.0; } } export function mountZoomKey(): () => void { window.addEventListener("keydown", handleZoomKey); return () => window.removeEventListener("keydown", handleZoomKey); } + +export function clampZoom(z: number, min: number, max: number): number { + return Math.round(Math.min(max, Math.max(min, z)) * 1000) / 1000; +} + +export function captureZoomAnchor( + containerEl: HTMLElement | null, + style: string, + out: { el: HTMLElement | null; offset: number }, +) { + if (!containerEl || style !== "longstrip") return; + const containerTop = containerEl.getBoundingClientRect().top; + for (const img of containerEl.querySelectorAll("img[data-local-page]")) { + const rect = img.getBoundingClientRect(); + if (rect.bottom > containerTop) { out.el = img; out.offset = rect.top - containerTop; return; } + } +} + +export function restoreZoomAnchor( + containerEl: HTMLElement | null, + out: { el: HTMLElement | null; offset: number }, +) { + if (!out.el || !containerEl) return; + const el = out.el; + out.el = null; + requestAnimationFrame(() => { + const containerTop = containerEl!.getBoundingClientRect().top; + containerEl!.scrollTop += (el.getBoundingClientRect().top - containerTop) - out.offset; + }); +} \ No newline at end of file diff --git a/src/features/reader/lib/index.ts b/src/features/reader/lib/index.ts index 506ba70..24aac27 100644 --- a/src/features/reader/lib/index.ts +++ b/src/features/reader/lib/index.ts @@ -3,7 +3,7 @@ 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 { setupScrollTracking, appendNextChapter } from "./lib/scrollHandler"; export type { StripChapter, ScrollHandlerCallbacks } from "./lib/scrollHandler"; export { createReaderKeyHandler } from "./lib/readerKeybinds"; export type { ReaderKeyActions } from "./lib/readerKeybinds"; @@ -11,4 +11,4 @@ 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"; +export { loadChapter, scheduleResumeDismiss } from "./lib/chapterLoader"; \ No newline at end of file diff --git a/src/features/reader/lib/pageLoader.ts b/src/features/reader/lib/pageLoader.ts index d582c39..a4e2b9d 100644 --- a/src/features/reader/lib/pageLoader.ts +++ b/src/features/reader/lib/pageLoader.ts @@ -1,83 +1,6 @@ -import { gql, plainThumbUrl } from "@api/client"; -import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache"; -import { dedupeRequest } from "@core/async/batchRequests"; -import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters"; +export { fetchPages, resolveUrl, preloadImage, measureAspect, clearPageCache } from "@core/cache/pageCache"; -export interface PageLoaderOptions { - useBlob: () => boolean; -} - -const pageCache = new Map(); -const inflight = new Map>(); -const resolvedUrlCache = new Map>(); -const preloadedUrls = new Set(); -const aspectCache = new Map(); - -export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise { - if (!useBlob) return Promise.resolve(url); - if (!resolvedUrlCache.has(url)) resolvedUrlCache.set(url, getBlobUrl(url, priority)); - return resolvedUrlCache.get(url)!; -} - -export function fetchPages( - chapterId: number, - useBlob: boolean, - signal?: AbortSignal, - priorityPage = 0, -): Promise { - const cached = pageCache.get(chapterId); - if (cached) return Promise.resolve(cached); - if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")); - - if (!inflight.has(chapterId)) { - const p = dedupeRequest(`chapter-pages:${chapterId}`, () => - gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId }) - .then(d => { - const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p)); - if (useBlob) { - if (urls[priorityPage]) getBlobUrl(urls[priorityPage], urls.length + 999); - preloadBlobUrls(urls.filter((_, i) => i !== priorityPage), urls.length); - } - pageCache.set(chapterId, urls); - return urls; - }) - ).finally(() => inflight.delete(chapterId)); - inflight.set(chapterId, p); - } - - const base = inflight.get(chapterId)!; - if (!signal) return base; - return new Promise((resolve, reject) => { - signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); - base.then(resolve, reject); - }); -} - -export function measureAspect(url: string, useBlob: boolean): Promise { - if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!); - return resolveUrl(url, useBlob).then(src => new Promise(res => { - const img = new Image(); - img.onload = () => { - const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; - aspectCache.set(url, r); - res(r); - }; - img.onerror = () => res(0.67); - img.src = src; - })); -} - -export function preloadImage(url: string, useBlob: boolean): void { - if (preloadedUrls.has(url)) return; - preloadedUrls.add(url); - resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {}); -} - -export function buildPageGroups( - urls: string[], - aspects: number[], - offsetSpreads: boolean, -): number[][] { +export function buildPageGroups(urls: string[], aspects: number[], offsetSpreads: boolean): number[][] { const groups: number[][] = [[1]]; if (offsetSpreads) groups.push([2]); let i = offsetSpreads ? 3 : 2; @@ -87,17 +10,4 @@ export function buildPageGroups( else { groups.push([i, i + 1]); i += 2; } } return groups; -} - -export function clearPageCache(chapterId?: number): void { - if (chapterId !== undefined) { - pageCache.delete(chapterId); - inflight.delete(chapterId); - } else { - pageCache.clear(); - inflight.clear(); - resolvedUrlCache.clear(); - preloadedUrls.clear(); - aspectCache.clear(); - } } \ No newline at end of file diff --git a/src/features/reader/lib/zoomHelpers.ts b/src/features/reader/lib/zoomHelpers.ts index 8932b2d..f628f93 100644 --- a/src/features/reader/lib/zoomHelpers.ts +++ b/src/features/reader/lib/zoomHelpers.ts @@ -1,38 +1,8 @@ -import { readerState } from "../store/readerState.svelte"; +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 { - const { ZOOM_MIN, ZOOM_MAX } = readerState; - return Math.round(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)) * 1000) / 1000; -} - -export function captureZoomAnchor( - containerEl: HTMLElement | null, - style: string, - out: { el: HTMLElement | null; offset: number }, -) { - if (!containerEl || style !== "longstrip") return; - const imgs = containerEl.querySelectorAll("img[data-local-page]"); - const containerTop = containerEl.getBoundingClientRect().top; - for (const img of imgs) { - const rect = img.getBoundingClientRect(); - if (rect.bottom > containerTop) { - out.el = img; - out.offset = rect.top - containerTop; - return; - } - } -} - -export function restoreZoomAnchor( - containerEl: HTMLElement | null, - out: { el: HTMLElement | null; offset: number }, -) { - if (!out.el || !containerEl) return; - const el = out.el; - out.el = null; - requestAnimationFrame(() => { - const containerTop = containerEl!.getBoundingClientRect().top; - const newRect = el.getBoundingClientRect(); - containerEl!.scrollTop += (newRect.top - containerTop) - out.offset; - }); -} + return _clampZoom(z, ZOOM_MIN, ZOOM_MAX); +} \ No newline at end of file