diff --git a/Cargo.lock b/Cargo.lock index 85e833f..61b1075 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -618,7 +618,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pixhelf" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 1ba69f5..5bb52dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pixhelf" -version = "0.1.3" +version = "0.1.4" edition = "2024" build = "build.rs" diff --git a/README.md b/README.md index 89c7653..3f178d1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ HTTP 服务会立即开放,并优先生成首批 60 张缩略图。每张图 首屏图库数据与压缩后的关键样式会随 HTML 一并下发,首张图片会提前加载。JS 使用内容版本化的长期缓存,API 使用 ETag 协商缓存,并按浏览器能力启用 Brotli 或 gzip 压缩。 -缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的版本化目录,原图不会被修改。缓存目录必须位于图库目录之外。 +缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的单一版本化目录,原图不会被修改。点击图片后,查看器会先显示这份缩略图,再加载原图,并提前预取相邻原图;超大原图会在浏览器内解码到 GPU 安全画布,不会生成第二套预览缓存。查看器支持键盘切换、滚轮缩放,以及移动端滑动、双击和双指缩放。缓存目录必须位于图库目录之外。 ## 构建 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 46e29f5..84601c0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "pixhelf-frontend", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pixhelf-frontend", - "version": "0.1.3", + "version": "0.1.4", "dependencies": { "preact": "^10.27.1" }, diff --git a/frontend/package.json b/frontend/package.json index 51768eb..5f24a46 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "pixhelf-frontend", "private": true, - "version": "0.1.3", + "version": "0.1.4", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/frontend/scripts/visual-check.mjs b/frontend/scripts/visual-check.mjs index 523c4a3..7c23c3c 100644 --- a/frontend/scripts/visual-check.mjs +++ b/frontend/scripts/visual-check.mjs @@ -63,7 +63,9 @@ try { cards: document.querySelectorAll("[data-image-id]").length, cardTags: [...document.querySelectorAll("[data-image-id]")] .map((element) => element.tagName), - masonryColumns: document.querySelectorAll(".masonry-column").length, + masonryColumns: Number( + document.querySelector(".masonry")?.getAttribute("data-columns") ?? 0, + ), loadedCards: document.querySelectorAll("[data-image-id] img.loaded").length, brokenVisibleImages: [...document.images].filter((image) => { const rect = image.getBoundingClientRect(); @@ -113,9 +115,240 @@ try { }; }); + let resizeStability = null; + if (target.name === "desktop") { + await page.evaluate(() => { + globalThis.__pixhelfResizeLoadedIds = [ + ...document.querySelectorAll('[data-image-id][data-loaded="true"]'), + ].map((card) => card.getAttribute("data-image-id")); + for (const card of document.querySelectorAll("[data-image-id]")) { + card.setAttribute( + "data-resize-probe", + card.getAttribute("data-image-id") ?? "", + ); + } + globalThis.__pixhelfResizeSkeletonSeen = false; + globalThis.__pixhelfResizeObserver = new MutationObserver(() => { + if (document.querySelector(".skeleton-grid")) { + globalThis.__pixhelfResizeSkeletonSeen = true; + } + }); + globalThis.__pixhelfResizeObserver.observe(document.body, { + childList: true, + subtree: true, + }); + globalThis.__pixhelfImageRequestsBeforeResize = performance + .getEntriesByType("resource") + .filter((entry) => new URL(entry.name).pathname === "/api/images") + .length; + }); + + for (const size of [ + { width: 680, columns: 3 }, + { width: 520, columns: 2 }, + { width: 1000, columns: 3 }, + { width: target.width, columns: 5 }, + ]) { + await page.setViewportSize({ width: size.width, height: target.height }); + await page.waitForFunction((columns) => + document.querySelector(".masonry")?.getAttribute("data-columns") === String(columns) + , size.columns); + await page.waitForTimeout(100); + } + + await page.waitForFunction(() => { + const loadedIds = globalThis.__pixhelfResizeLoadedIds ?? []; + return loadedIds.every((id) => { + const card = document.querySelector(`[data-image-id="${CSS.escape(id)}"]`); + const image = card?.querySelector("img"); + return card?.getAttribute("data-loaded") === "true" + && image?.complete + && image.naturalWidth > 0; + }); + }); + resizeStability = await page.evaluate(() => { + globalThis.__pixhelfResizeObserver?.disconnect(); + const cards = [...document.querySelectorAll("[data-image-id]")]; + const imageRequestsAfter = performance + .getEntriesByType("resource") + .filter((entry) => new URL(entry.name).pathname === "/api/images") + .length; + const visibleImages = cards + .map((card) => card.querySelector("img")) + .filter((image) => { + if (!image) return false; + const rect = image.getBoundingClientRect(); + return rect.bottom > 0 && rect.top < innerHeight; + }); + return { + cardsPreserved: cards.every((card) => + card.getAttribute("data-resize-probe") === card.getAttribute("data-image-id") + ), + loadedCardsPreserved: (globalThis.__pixhelfResizeLoadedIds ?? []).every((id) => + document.querySelector(`[data-image-id="${CSS.escape(id)}"]`) + ?.getAttribute("data-loaded") === "true" + ), + skeletonSeen: globalThis.__pixhelfResizeSkeletonSeen, + imagePageRequests: imageRequestsAfter + - globalThis.__pixhelfImageRequestsBeforeResize, + brokenVisibleImages: visibleImages.filter((image) => + !image.complete || image.naturalWidth <= 0 + ).length, + columns: Number( + document.querySelector(".masonry")?.getAttribute("data-columns") ?? 0, + ), + }; + }); + } + + const firstViewerTitle = await page.locator("[data-image-id]").first().getAttribute("title"); await page.locator("[data-image-id]").first().click(); - await page.waitForTimeout(200); + await page.waitForSelector(".image-viewer"); + const viewerChrome = await page.evaluate(() => ({ + fullscreenButtons: document.querySelectorAll(".viewer-fullscreen").length, + gestureHelp: document.querySelectorAll(".viewer-gesture-help").length, + loadingStatus: document.querySelectorAll(".viewer-load-status").length, + loadingCopyVisible: document.body.textContent?.includes("正在加载原图") ?? false, + })); + await page.waitForFunction(() => + document.querySelector(".image-viewer")?.getAttribute("data-full-loaded") === "true" + , undefined, { timeout: 60_000 }); + await page.waitForFunction(() => { + const originalPaths = performance.getEntriesByType("resource") + .map((entry) => new URL(entry.name).pathname) + .filter((path) => path.endsWith("/original")); + return new Set(originalPaths).size >= 2; + }, undefined, { timeout: 60_000 }); + await page.waitForTimeout(260); const dialogsAfterImageClick = await page.locator('[role="dialog"]').count(); + await page.screenshot({ path: `/tmp/pixhelf-viewer-${target.name}.png` }); + const viewer = await page.evaluate(() => { + const overlay = document.querySelector(".image-viewer"); + const original = document.querySelector(".viewer-original"); + const media = document.querySelector(".viewer-media")?.getBoundingClientRect(); + const resourcePaths = performance.getEntriesByType("resource") + .map((entry) => new URL(entry.name).pathname); + const originalSource = original instanceof HTMLImageElement + ? original.currentSrc || original.src + : original?.getAttribute("data-original-url") ?? ""; + return { + title: document.querySelector(".viewer-heading strong")?.textContent ?? "", + fullLoaded: overlay?.getAttribute("data-full-loaded"), + originalPath: originalSource ? new URL(originalSource, location.href).pathname : "", + renderer: overlay?.getAttribute("data-renderer"), + renderedPixels: original instanceof HTMLCanvasElement + ? [original.width, original.height] + : [original?.clientWidth ?? 0, original?.clientHeight ?? 0], + prefetchedOriginals: new Set( + resourcePaths.filter((path) => path.endsWith("/original")), + ).size, + previewRequests: resourcePaths.filter((path) => path.endsWith("/preview")).length, + rootInert: document.querySelector("#root")?.inert ?? false, + closeFocused: document.activeElement?.classList.contains("viewer-close") ?? false, + mediaContained: media + ? media.left >= -1 && media.top >= -1 && media.right <= innerWidth + 1 && + media.bottom <= innerHeight + 1 + : false, + position: document.querySelector(".viewer-heading span")?.textContent ?? "", + }; + }); + Object.assign(viewer, viewerChrome); + await page.locator(".viewer-fullscreen").click(); + await page.waitForFunction(() => + document.fullscreenElement?.classList.contains("image-viewer") + ); + await page.waitForFunction(() => + document.querySelector(".viewer-fullscreen")?.getAttribute("aria-label") === "退出全屏" + ); + viewer.fullscreenEntered = true; + viewer.fullscreenLabel = await page.locator(".viewer-fullscreen").getAttribute("aria-label"); + await page.locator(".viewer-fullscreen").click(); + await page.waitForFunction(() => document.fullscreenElement === null); + viewer.fullscreenExited = true; + await page.locator(".viewer-zoom-controls .viewer-control").last().click(); + await page.waitForFunction(() => + document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "true" + ); + viewer.zoomed = await page.locator(".viewer-zoom-value").textContent(); + await page.waitForTimeout(260); + viewer.zoomScale = await page.locator(".viewer-media").evaluate((media) => + new DOMMatrix(getComputedStyle(media).transform).a + ); + await page.keyboard.press("ArrowRight"); + await page.waitForFunction((title) => + document.querySelector(".viewer-heading strong")?.textContent !== title + , firstViewerTitle); + viewer.keyboardNavigation = await page.locator(".viewer-heading strong").textContent(); + if (target.name === "mobile") { + const cdp = await page.context().newCDPSession(page); + const dispatchTouch = (type, touchPoints) => cdp.send("Input.dispatchTouchEvent", { + type, + touchPoints: touchPoints.map((point) => ({ + ...point, + radiusX: 2, + radiusY: 2, + force: 1, + })), + }); + + await page.waitForFunction(() => + document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "false" + ); + await dispatchTouch("touchStart", [ + { id: 11, x: 145, y: 420 }, + { id: 12, x: 245, y: 420 }, + ]); + await page.waitForTimeout(32); + await dispatchTouch("touchMove", [ + { id: 11, x: 95, y: 420 }, + { id: 12, x: 295, y: 420 }, + ]); + await page.waitForTimeout(32); + await dispatchTouch("touchEnd", []); + await page.waitForFunction(() => + document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "true" + ); + viewer.pinchZoomed = true; + await page.waitForTimeout(40); + viewer.pinchScale = await page.locator(".viewer-media").evaluate((media) => + new DOMMatrix(getComputedStyle(media).transform).a + ); + + await page.touchscreen.tap(195, 420); + await page.waitForTimeout(70); + await page.touchscreen.tap(195, 420); + await page.waitForFunction(() => + document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "false" + ); + viewer.doubleTapReset = true; + await page.waitForTimeout(260); + viewer.doubleTapScale = await page.locator(".viewer-media").evaluate((media) => + new DOMMatrix(getComputedStyle(media).transform).a + ); + + const titleBeforeSwipe = await page.locator(".viewer-heading strong").textContent(); + await dispatchTouch("touchStart", [{ id: 31, x: 320, y: 420 }]); + await page.waitForTimeout(32); + await dispatchTouch("touchMove", [{ id: 31, x: 90, y: 420 }]); + await page.waitForTimeout(32); + await dispatchTouch("touchEnd", []); + await page.waitForFunction((title) => + document.querySelector(".viewer-heading strong")?.textContent !== title + , titleBeforeSwipe); + viewer.swipeNavigation = await page.locator(".viewer-heading strong").textContent(); + + await dispatchTouch("touchStart", [{ id: 41, x: 195, y: 210 }]); + await page.waitForTimeout(32); + await dispatchTouch("touchMove", [{ id: 41, x: 195, y: 510 }]); + await page.waitForTimeout(32); + await dispatchTouch("touchEnd", []); + viewer.closedByGesture = true; + } else { + await page.keyboard.press("Escape"); + } + await page.waitForSelector(".image-viewer", { state: "detached" }); + viewer.closed = await page.locator(".image-viewer").count(); + viewer.rootRestored = await page.locator("#root").evaluate((root) => !root.inert); const searchToggleBefore = await page.locator(".search-toggle").boundingBox(); await page.locator(".search-toggle").click(); @@ -314,6 +547,10 @@ try { ); } const clickCount = await page.evaluate(() => globalThis.__pixhelfCardClickCount ?? 0); + await page.waitForSelector(".image-viewer"); + const viewerOpenedFromTouch = await page.locator(".image-viewer").count(); + await page.locator(".viewer-close").click(); + await page.waitForSelector(".image-viewer", { state: "detached" }); mobileCardNames = { shownOnContact, retainedOnRelease, @@ -323,6 +560,7 @@ try { activeCount, displayedCount, clickCount, + viewerOpenedFromTouch, }; } @@ -361,7 +599,9 @@ try { ).visibility, ariaHidden: document.querySelector(".desktop-sidebar")?.getAttribute("aria-hidden"), stored: localStorage.getItem("pixhelf.sidebar-collapsed"), - columns: document.querySelectorAll(".masonry-column").length, + columns: Number( + document.querySelector(".masonry")?.getAttribute("data-columns") ?? 0, + ), redundantHeadings: document.querySelectorAll(".sidebar-heading").length, pathDetails: document.querySelectorAll(".album-copy small").length, progressPanels: document.querySelectorAll(".sidebar-progress").length, @@ -505,7 +745,9 @@ try { const result = { name: target.name, ...layout, + resizeStability, dialogsAfterImageClick, + viewer, searchDisclosure, exploration, homeNavigation, @@ -524,7 +766,40 @@ try { layout.masonryColumns !== expectedColumns || layout.cardTags.some((tag) => tag !== "FIGURE") || layout.dialogs !== 0 || - dialogsAfterImageClick !== 0 || + dialogsAfterImageClick !== 1 || + viewer.title !== firstViewerTitle || + viewer.fullLoaded !== "true" || + !viewer.originalPath.endsWith("/original") || + viewer.renderer !== "bitmap" || + viewer.renderedPixels.some((size) => size <= 0 || size > 4096) || + viewer.prefetchedOriginals < 2 || + viewer.previewRequests !== 0 || + viewer.fullscreenButtons !== 1 || + viewer.gestureHelp !== 0 || + viewer.loadingStatus !== 0 || + viewer.loadingCopyVisible || + !viewer.fullscreenEntered || + viewer.fullscreenLabel !== "退出全屏" || + !viewer.fullscreenExited || + !viewer.rootInert || + !viewer.closeFocused || + !viewer.mediaContained || + !viewer.position.includes("/") || + viewer.zoomed !== "150%" || + Math.abs(viewer.zoomScale - 1.5) > 0.02 || + !viewer.keyboardNavigation || + viewer.keyboardNavigation === firstViewerTitle || + (target.name === "mobile" && ( + !viewer.pinchZoomed || + viewer.pinchScale < 1.5 || + !viewer.doubleTapReset || + Math.abs(viewer.doubleTapScale - 1) > 0.02 || + !viewer.swipeNavigation || + viewer.swipeNavigation === viewer.keyboardNavigation || + !viewer.closedByGesture + )) || + viewer.closed !== 0 || + !viewer.rootRestored || searchDisclosure.expanded !== "true" || !searchDisclosure.focused || searchDisclosure.panelRole !== "group" || @@ -563,6 +838,15 @@ try { layout.exploreSearchGap > 8 || layout.sidebarStatuses !== 1 || layout.sidebarGalleryMetas !== 1 || + (target.name === "desktop" && ( + !resizeStability || + !resizeStability.cardsPreserved || + !resizeStability.loadedCardsPreserved || + resizeStability.skeletonSeen || + resizeStability.imagePageRequests !== 0 || + resizeStability.brokenVisibleImages !== 0 || + resizeStability.columns !== 5 + )) || !layout.galleryPath.startsWith("/") || !layout.galleryCount.endsWith("张图片") || layout.galleryMetaRowDelta < 0 || @@ -643,6 +927,7 @@ try { mobileCardNames.activeCount !== 1 || mobileCardNames.displayedCount !== 1 || mobileCardNames.clickCount !== 1 || + mobileCardNames.viewerOpenedFromTouch !== 1 || layout.cardNameDisplay === "none" || layout.cardNameOpacity > 0.01 || layout.cardNameWhiteSpace !== "nowrap" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 01d0abb..392cc21 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import { useState, } from "preact/hooks"; import { ToolbarPopover } from "./ToolbarPopover"; +import { ImageViewer, preloadOriginalImage } from "./ImageViewer"; import { getGallery, getImages, getStatus, takeInitialBootstrap } from "./api"; import type { GalleryImage, @@ -30,7 +31,6 @@ import type { } from "./types"; const PAGE_SIZE = 60; -const MOBILE_PAGE_SIZE = 48; const CARD_PREFETCH_MARGIN = "1200px 0px"; const MOBILE_PAGE_PREFETCH_MARGIN = "1400px 0px"; const DESKTOP_PAGE_PREFETCH_MARGIN = "900px 0px"; @@ -168,42 +168,31 @@ function createExploreSeed(): string { } const CARD_LOAD_CALLBACKS = new WeakMap void>(); +const READY_THUMBNAIL_IDS = new Set(); let cardLoadObserver: IntersectionObserver | null = null; function observeCardLoad(element: Element, load: () => void): () => void { - let observing = false; - const startObserving = () => { - if (!("IntersectionObserver" in window)) { - load(); - return; - } - if (!cardLoadObserver) { - cardLoadObserver = new IntersectionObserver( - (entries, observer) => { - for (const entry of entries) { - if (!entry.isIntersecting) continue; - const callback = CARD_LOAD_CALLBACKS.get(entry.target); - CARD_LOAD_CALLBACKS.delete(entry.target); - observer.unobserve(entry.target); - callback?.(); - } - }, - { rootMargin: CARD_PREFETCH_MARGIN }, - ); - } - observing = true; - CARD_LOAD_CALLBACKS.set(element, load); - cardLoadObserver.observe(element); - }; - - if (document.readyState === "complete") { - startObserving(); - } else { - window.addEventListener("load", startObserving, { once: true }); + if (!("IntersectionObserver" in window)) { + load(); + return () => undefined; } + if (!cardLoadObserver) { + cardLoadObserver = new IntersectionObserver( + (entries, observer) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const callback = CARD_LOAD_CALLBACKS.get(entry.target); + CARD_LOAD_CALLBACKS.delete(entry.target); + observer.unobserve(entry.target); + callback?.(); + } + }, + { rootMargin: CARD_PREFETCH_MARGIN }, + ); + } + CARD_LOAD_CALLBACKS.set(element, load); + cardLoadObserver.observe(element); return () => { - window.removeEventListener("load", startObserving); - if (!observing) return; CARD_LOAD_CALLBACKS.delete(element); cardLoadObserver?.unobserve(element); }; @@ -234,10 +223,10 @@ function App() { const [error, setError] = useState(null); const [mobileNavOpen, setMobileNavOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(initialSidebarCollapsed); + const [viewerImageId, setViewerImageId] = useState(null); const [reloadToken, setReloadToken] = useState(0); const compactLayout = useMediaQuery("(max-width: 720px)"); const mobileNavMounted = useDelayedUnmount(mobileNavOpen, MOBILE_NAV_EXIT_MS); - const pageSize = compactLayout ? MOBILE_PAGE_SIZE : PAGE_SIZE; const debouncedSearch = useDebounced(search.trim(), 250); const requestVersionRef = useRef(0); const summaryRevisionRef = useRef( @@ -245,7 +234,7 @@ function App() { ); const skipInitialImagesRef = useRef(Boolean(INITIAL_BOOTSTRAP)); const loadMoreControllerRef = useRef(null); - const loadingMoreRef = useRef(false); + const loadMorePromiseRef = useRef | null>(null); const { images, total, nextOffset } = imagePage; useLayoutEffect(() => { @@ -361,7 +350,7 @@ function App() { const requestVersion = ++requestVersionRef.current; loadMoreControllerRef.current?.abort(); loadMoreControllerRef.current = null; - loadingMoreRef.current = false; + loadMorePromiseRef.current = null; setLoading(true); setLoadingMore(false); setError(null); @@ -373,7 +362,7 @@ function App() { sort: exploreSeed ? "explore" : "name-asc", seed: exploreSeed, offset: 0, - limit: pageSize, + limit: PAGE_SIZE, }, controller.signal, ) @@ -399,48 +388,57 @@ function App() { controller.abort(); const loadMoreController = loadMoreControllerRef.current; loadMoreControllerRef.current = null; - loadingMoreRef.current = false; + loadMorePromiseRef.current = null; loadMoreController?.abort(); }; - }, [album, debouncedSearch, exploreSeed, pageSize, reloadToken]); + }, [album, debouncedSearch, exploreSeed, reloadToken]); + + const loadMore = useCallback((): Promise => { + if (loading || nextOffset === null) return Promise.resolve([]); + const pending = loadMorePromiseRef.current; + if (pending) return pending; - const loadMore = useCallback(async () => { - if (loading || nextOffset === null || loadingMoreRef.current) return; const controller = new AbortController(); const requestVersion = requestVersionRef.current; loadMoreControllerRef.current = controller; - loadingMoreRef.current = true; setLoadingMore(true); - try { - const page = await getImages( - { - album, - search: debouncedSearch, - sort: exploreSeed ? "explore" : "name-asc", - seed: exploreSeed, - offset: nextOffset, - limit: pageSize, - }, - controller.signal, - ); - if (requestVersion !== requestVersionRef.current) return; - setImagePage((current) => ({ - images: appendUniqueImages(current.images, page.items), - total: page.total, - nextOffset: page.nextOffset, - })); - } catch (reason) { - if (!controller.signal.aborted && requestVersion === requestVersionRef.current) { - setError(errorMessage(reason, "无法继续加载图片")); + + const promise = (async () => { + try { + const page = await getImages( + { + album, + search: debouncedSearch, + sort: exploreSeed ? "explore" : "name-asc", + seed: exploreSeed, + offset: nextOffset, + limit: PAGE_SIZE, + }, + controller.signal, + ); + if (requestVersion !== requestVersionRef.current) return []; + setImagePage((current) => ({ + images: appendUniqueImages(current.images, page.items), + total: page.total, + nextOffset: page.nextOffset, + })); + return page.items; + } catch (reason) { + if (!controller.signal.aborted && requestVersion === requestVersionRef.current) { + setError(errorMessage(reason, "无法继续加载图片")); + } + return []; + } finally { + if (loadMoreControllerRef.current === controller) { + loadMoreControllerRef.current = null; + loadMorePromiseRef.current = null; + if (requestVersion === requestVersionRef.current) setLoadingMore(false); + } } - } finally { - if (loadMoreControllerRef.current === controller) { - loadMoreControllerRef.current = null; - loadingMoreRef.current = false; - if (requestVersion === requestVersionRef.current) setLoadingMore(false); - } - } - }, [album, debouncedSearch, exploreSeed, loading, nextOffset, pageSize]); + })(); + loadMorePromiseRef.current = promise; + return promise; + }, [album, debouncedSearch, exploreSeed, loading, nextOffset]); const sentinelRef = useRef(null); useEffect(() => { @@ -460,18 +458,49 @@ function App() { return () => observer.disconnect(); }, [compactLayout, loadMore, nextOffset]); + const viewerIndex = viewerImageId + ? images.findIndex((image) => image.id === viewerImageId) + : -1; + + useEffect(() => { + if (!viewerImageId || viewerIndex < 0 || nextOffset === null) return; + if (viewerIndex >= images.length - 5) void loadMore(); + }, [images.length, loadMore, nextOffset, viewerImageId, viewerIndex]); + + const navigateViewer = useCallback((direction: -1 | 1) => { + if (!viewerImageId) return; + const currentIndex = images.findIndex((image) => image.id === viewerImageId); + if (currentIndex < 0) return; + const target = images[currentIndex + direction]; + if (target) { + setViewerImageId(target.id); + return; + } + if (direction < 0 || nextOffset === null) return; + + const startingId = viewerImageId; + void loadMore().then((incoming) => { + const next = incoming[0]; + if (!next) return; + setViewerImageId((current) => current === startingId ? next.id : current); + }); + }, [images, loadMore, nextOffset, viewerImageId]); + const galleryPath = `/${album.replace(/^\/+/, "")}`; const chooseAlbum = (path: string) => { window.scrollTo({ top: 0, left: 0, behavior: "auto" }); setAlbum(path); setExploreSeed(""); + setViewerImageId(null); setMobileNavOpen(false); }; const changeSearch = (value: string) => { setSearch(value); setExploreSeed(""); + setViewerImageId(null); }; const startExploring = () => { + setViewerImageId(null); setExploreSeed(createExploreSeed()); }; const goHome = () => { @@ -479,6 +508,7 @@ function App() { setAlbum(""); setSearch(""); setExploreSeed(""); + setViewerImageId(null); setMobileNavOpen(false); }; return ( @@ -552,6 +582,7 @@ function App() { ) : (
@@ -570,6 +601,17 @@ function App() { )}
+ {viewerIndex >= 0 && ( + setViewerImageId(null)} + /> + )} ); } @@ -889,114 +931,126 @@ function AlbumButton({ ); } -function useColumnCount( +type MasonryMetrics = { + width: number; + columnCount: number; + gap: number; +}; + +function useMasonryMetrics( ref: RefObject, initialColumnCount: number, -): number { - const [columns, setColumns] = useState(initialColumnCount); +): MasonryMetrics { + const [metrics, setMetrics] = useState({ + width: 0, + columnCount: initialColumnCount, + gap: 6, + }); useLayoutEffect(() => { const element = ref.current; if (!element) return; + let frame = 0; + let pendingWidth = element.clientWidth; const update = (width: number) => { + if (width <= 0) return; const minimumCardWidth = 218; - const gap = 6; + const declaredGap = Number.parseFloat( + getComputedStyle(element).getPropertyValue("--masonry-gap"), + ); + const gap = Number.isFinite(declaredGap) ? declaredGap : 6; const next = Math.floor((width + gap) / (minimumCardWidth + gap)); - setColumns(Math.min(6, Math.max(2, next))); + const roundedWidth = Math.round(width * 100) / 100; + const columnCount = Math.min(6, Math.max(2, next)); + setMetrics((current) => ( + current.width === roundedWidth + && current.columnCount === columnCount + && current.gap === gap + ? current + : { width: roundedWidth, columnCount, gap } + )); }; - update(element.clientWidth); - const observer = new ResizeObserver(([entry]) => update(entry.contentRect.width)); + const schedule = (width: number) => { + pendingWidth = width; + if (frame) return; + frame = window.requestAnimationFrame(() => { + frame = 0; + update(pendingWidth); + }); + }; + + update(pendingWidth); + const observer = new ResizeObserver(([entry]) => schedule(entry.contentRect.width)); observer.observe(element); - return () => observer.disconnect(); - }, [initialColumnCount, ref]); - return columns; + return () => { + if (frame) window.cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, [ref]); + return metrics; } function MasonryGallery({ images, initialColumnCount, + onOpen, }: { images: GalleryImage[]; initialColumnCount: number; + onOpen: (id: string) => void; }) { const ref = useRef(null); const [activeNameId, setActiveNameId] = useState(null); - const [readyRevealRows, setReadyRevealRows] = useState>( - () => new Set(), - ); - const settledRevealIdsRef = useRef(new Set()); - const revealRowMembersRef = useRef([]); - const columnCount = useColumnCount(ref, initialColumnCount); - const eagerRowCount = 1; + const { width, columnCount, gap } = useMasonryMetrics(ref, initialColumnCount); const showName = useCallback((id: string) => setActiveNameId(id), []); - const markRevealImageSettled = useCallback((rowIndex: number, id: string) => { - settledRevealIdsRef.current.add(id); - const rowMembers = revealRowMembersRef.current[rowIndex]; - if (!rowMembers?.every((memberId) => settledRevealIdsRef.current.has(memberId))) return; - - setReadyRevealRows((current) => { - if (current.has(rowIndex)) return current; - const next = new Set(current); - next.add(rowIndex); - return next; - }); - }, []); - - const columns = useMemo(() => { - const result: { image: GalleryImage; index: number }[][] = Array.from( - { length: columnCount }, - () => [], - ); + const layout = useMemo(() => { + if (width <= 0) return { height: 0, items: [] }; + const cardWidth = Math.max(1, (width - gap * (columnCount - 1)) / columnCount); const heights = Array(columnCount).fill(0) as number[]; - images.forEach((image, index) => { + const items = images.map((image, index) => { const target = heights.indexOf(Math.min(...heights)); - result[target].push({ image, index }); - heights[target] += image.height / Math.max(image.width, 1) + 0.05; + const cardHeight = Math.max( + 64, + cardWidth * image.height / Math.max(image.width, 1), + ); + const top = heights[target]; + heights[target] = top + cardHeight + gap; + return { + image, + index, + style: { + left: target * (cardWidth + gap), + top, + width: cardWidth, + height: cardHeight, + } as CSSProperties, + }; }); - return result; - }, [columnCount, images]); - - // Masonry row indices drift apart vertically, so only coordinate the first row. - const synchronizedRowCount = columnCount <= 2 && images.length > 0 ? 1 : 0; - const synchronizedRowMembers = useMemo( - () => Array.from({ length: synchronizedRowCount }, (_, rowIndex) => ( - columns - .map((column) => column[rowIndex]?.image.id) - .filter((id): id is string => Boolean(id)) - )), - [columns, synchronizedRowCount], - ); - - useLayoutEffect(() => { - revealRowMembersRef.current = synchronizedRowMembers; - }, [synchronizedRowMembers]); + return { + items, + height: items.length ? Math.max(...heights) - gap : 0, + }; + }, [columnCount, gap, images, width]); return (
- {columns.map((column, columnIndex) => ( -
- {column.map(({ image, index }, rowIndex) => { - const synchronizeReveal = rowIndex < synchronizedRowCount; - return ( - - ); - })} -
+ {layout.items.map(({ image, index, style }) => ( + ))}
); @@ -1004,28 +1058,29 @@ function MasonryGallery({ const ImageCard = memo(function ImageCard({ image, + layoutStyle, eager, highPriority, - revealReady, - revealGroup, nameVisible, onNameTouch, - onSettled, + onOpen, }: { image: GalleryImage; + layoutStyle: CSSProperties; eager: boolean; highPriority: boolean; - revealReady: boolean; - revealGroup?: number; nameVisible: boolean; onNameTouch: (id: string) => void; - onSettled?: (rowIndex: number, id: string) => void; + onOpen: (id: string) => void; }) { - const [loaded, setLoaded] = useState(false); + const alreadyReady = READY_THUMBNAIL_IDS.has(image.id); + const [loaded, setLoaded] = useState(alreadyReady); const [failed, setFailed] = useState(false); + const [retrying, setRetrying] = useState(false); const [attempt, setAttempt] = useState(0); - const [loadRequested, setLoadRequested] = useState(eager); + const [loadRequested, setLoadRequested] = useState(eager || alreadyReady); const cardRef = useRef(null); + const imageRef = useRef(null); const retryTimerRef = useRef(0); useEffect(() => () => window.clearTimeout(retryTimerRef.current), []); @@ -1040,24 +1095,41 @@ const ImageCard = memo(function ImageCard({ return observeCardLoad(card, () => setLoadRequested(true)); }, [eager, loadRequested]); - const handleError = () => { - setLoaded(false); - setFailed(true); - const delay = IMAGE_RETRY_DELAYS_MS[attempt]; - if (delay === undefined) { - if (revealGroup !== undefined) onSettled?.(revealGroup, image.id); - return; - } - retryTimerRef.current = window.setTimeout(() => { - setAttempt((current) => current + 1); - setFailed(false); - }, delay); - }; - const thumbnailUrl = `/api/images/${encodeURIComponent(image.id)}/thumbnail`; const retryQuery = attempt ? `retry=${attempt}` : ""; const imageUrl = retryQuery ? `${thumbnailUrl}?${retryQuery}` : thumbnailUrl; - const revealed = loaded && revealReady; + + const markLoaded = useCallback(() => { + window.clearTimeout(retryTimerRef.current); + READY_THUMBNAIL_IDS.add(image.id); + setLoaded(true); + setFailed(false); + setRetrying(false); + }, [image.id]); + + useLayoutEffect(() => { + const element = imageRef.current; + if (element?.complete && element.naturalWidth > 0) markLoaded(); + }, [imageUrl, loadRequested, markLoaded]); + + const handleError = () => { + window.clearTimeout(retryTimerRef.current); + READY_THUMBNAIL_IDS.delete(image.id); + setLoaded(false); + const delay = IMAGE_RETRY_DELAYS_MS[attempt]; + if (delay === undefined) { + setRetrying(false); + setFailed(true); + return; + } + setFailed(false); + setRetrying(true); + retryTimerRef.current = window.setTimeout(() => { + setAttempt((current) => current + 1); + setRetrying(false); + }, delay); + }; + return (
preloadOriginalImage(image)} onPointerDown={(event) => { + preloadOriginalImage(image); if (event.pointerType !== "mouse") onNameTouch(image.id); }} + onFocus={() => preloadOriginalImage(image)} + onClick={() => onOpen(image.id)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onOpen(image.id); + }} + role="button" + tabIndex={0} + aria-label={`查看 ${image.name}`} + aria-haspopup="dialog" > {!failed && loadRequested ? ( {image.name} { - setLoaded(true); - if (revealGroup !== undefined) onSettled?.(revealGroup, image.id); - }} + width={image.width} + height={image.height} + draggable={false} + className={loaded ? "loaded" : ""} + onLoad={markLoaded} onError={handleError} /> ) : failed ? ( diff --git a/frontend/src/ImageViewer.tsx b/frontend/src/ImageViewer.tsx new file mode 100644 index 0000000..8616fcb --- /dev/null +++ b/frontend/src/ImageViewer.tsx @@ -0,0 +1,1046 @@ +import type { CSSProperties, JSX } from "preact"; +import { createPortal } from "preact/compat"; +import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; +import { + ChevronLeft, + ChevronRight, + Download, + LoaderCircle, + Maximize2, + Minus, + Minimize2, + Plus, + RefreshCw, + X, +} from "./icons"; +import type { GalleryImage } from "./types"; + +const MIN_SCALE = 1; +const MAX_SCALE = 5; +const DOUBLE_TAP_SCALE = 2.5; +const DOUBLE_TAP_DELAY_MS = 280; +const ORIGINAL_PRELOAD_LIMIT = 6; +const SECOND_NEIGHBOR_PRELOAD_DELAY_MS = 500; +const MAX_RENDER_EDGE = 4096; + +type ViewerTransform = { + scale: number; + x: number; + y: number; +}; + +type PointerPoint = { + x: number; + y: number; +}; + +type GestureStart = { + point: PointerPoint; + transform: ViewerTransform; + startedAt: number; + pointerType: string; +}; + +type PinchStart = { + distance: number; + midpoint: PointerPoint; + transform: ViewerTransform; +}; + +type OriginalLoadState = { + id: string; + attempt: number; + loaded: boolean; + failed: boolean; +}; + +type NetworkInformation = { + effectiveType?: string; + saveData?: boolean; +}; + +type ViewportSize = { + width: number; + height: number; +}; + +type GestureHandlers = { + begin: (id: number, point: PointerPoint, pointerType: string) => void; + move: (id: number, point: PointerPoint) => void; + finish: (id: number, point: PointerPoint, pointerType: string) => void; + cancel: () => void; +}; + +const DEFAULT_TRANSFORM: ViewerTransform = { scale: 1, x: 0, y: 0 }; +const originalPreloads = new Map(); + +function readViewportSize(): ViewportSize { + return { + width: window.visualViewport?.width ?? window.innerWidth, + height: window.visualViewport?.height ?? window.innerHeight, + }; +} + +function useViewportSize(): ViewportSize { + const [size, setSize] = useState(readViewportSize); + useEffect(() => { + const viewport = window.visualViewport; + let frame = 0; + const schedule = () => { + if (frame) return; + frame = window.requestAnimationFrame(() => { + frame = 0; + setSize(readViewportSize()); + }); + }; + window.addEventListener("resize", schedule); + window.addEventListener("orientationchange", schedule); + viewport?.addEventListener("resize", schedule); + return () => { + if (frame) window.cancelAnimationFrame(frame); + window.removeEventListener("resize", schedule); + window.removeEventListener("orientationchange", schedule); + viewport?.removeEventListener("resize", schedule); + }; + }, []); + return size; +} + +function thumbnailUrl(image: GalleryImage): string { + return `/api/images/${encodeURIComponent(image.id)}/thumbnail`; +} + +function originalUrl(image: GalleryImage, attempt = 0): string { + const url = `/api/images/${encodeURIComponent(image.id)}/original`; + return attempt ? `${url}?retry=${attempt}` : url; +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.min(maximum, Math.max(minimum, value)); +} + +function pointDistance(first: PointerPoint, second: PointerPoint): number { + return Math.hypot(second.x - first.x, second.y - first.y); +} + +function pointMidpoint(first: PointerPoint, second: PointerPoint): PointerPoint { + return { + x: (first.x + second.x) / 2, + y: (first.y + second.y) / 2, + }; +} + +function rubberBand(distance: number, limit: number): number { + if (!distance) return 0; + return Math.sign(distance) * limit * (1 - Math.exp(-Math.abs(distance) / limit)); +} + +function shouldPreloadViewerImages(): boolean { + if (document.visibilityState !== "visible") return false; + const connection = (navigator as Navigator & { connection?: NetworkInformation }).connection; + if (connection?.saveData) return false; + return connection?.effectiveType !== "slow-2g" && connection?.effectiveType !== "2g"; +} + +export function preloadOriginalImage(image: GalleryImage): void { + if (!shouldPreloadViewerImages()) return; + const url = originalUrl(image); + if (originalPreloads.has(url)) return; + + const loader = new Image(); + loader.decoding = "async"; + loader.fetchPriority = "low"; + loader.src = url; + originalPreloads.set(url, loader); + + while (originalPreloads.size > ORIGINAL_PRELOAD_LIMIT) { + const oldestUrl = originalPreloads.keys().next().value as string | undefined; + if (!oldestUrl) break; + const oldest = originalPreloads.get(oldestUrl); + if (oldest && !oldest.complete) oldest.src = ""; + originalPreloads.delete(oldestUrl); + } +} + +export function ImageViewer({ + images, + activeIndex, + total, + hasMore, + loadingMore, + onNavigate, + onClose, +}: { + images: GalleryImage[]; + activeIndex: number; + total: number; + hasMore: boolean; + loadingMore: boolean; + onNavigate: (direction: -1 | 1) => void; + onClose: () => void; +}) { + const image = images[activeIndex]!; + const viewport = useViewportSize(); + const canPrevious = activeIndex > 0; + const canNext = activeIndex < images.length - 1 || hasMore; + const waitingForNext = loadingMore && activeIndex === images.length - 1; + const dialogRef = useRef(null); + const closeButtonRef = useRef(null); + const surfaceRef = useRef(null); + const mediaRef = useRef(null); + const canvasRef = useRef(null); + const pointersRef = useRef(new Map()); + const gestureStartRef = useRef(null); + const pinchStartRef = useRef(null); + const gestureAxisRef = useRef<"x" | "y" | null>(null); + const pinchedRef = useRef(false); + const lastTapRef = useRef<{ at: number; point: PointerPoint } | null>(null); + const lastTouchAtRef = useRef(0); + const gestureHandlersRef = useRef(null); + const retryTimerRef = useRef(0); + const [dragging, setDragging] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + const [fullscreenAvailable, setFullscreenAvailable] = useState( + () => typeof document !== "undefined" + && document.fullscreenEnabled + && "requestFullscreen" in HTMLElement.prototype, + ); + const [transform, setTransform] = useState(DEFAULT_TRANSFORM); + const transformRef = useRef(DEFAULT_TRANSFORM); + const [nativeFallbackId, setNativeFallbackId] = useState(null); + const [loadState, setLoadState] = useState({ + id: image.id, + attempt: 0, + loaded: false, + failed: false, + }); + const currentLoadState = loadState.id === image.id + ? loadState + : { id: image.id, attempt: 0, loaded: false, failed: false }; + const fullSource = originalUrl(image, currentLoadState.attempt); + const useBitmapRenderer = typeof createImageBitmap === "function" + && nativeFallbackId !== image.id; + + const commitTransform = (next: ViewerTransform) => { + transformRef.current = next; + setTransform(next); + }; + + const constrainTransform = (next: ViewerTransform): ViewerTransform => { + const scale = clamp(next.scale, MIN_SCALE, MAX_SCALE); + if (scale <= MIN_SCALE) return DEFAULT_TRANSFORM; + + const surface = surfaceRef.current; + const media = mediaRef.current; + if (!surface || !media) return { scale, x: next.x, y: next.y }; + const maximumX = Math.max(0, (media.offsetWidth * scale - surface.clientWidth) / 2); + const maximumY = Math.max(0, (media.offsetHeight * scale - surface.clientHeight) / 2); + return { + scale, + x: clamp(next.x, -maximumX, maximumX), + y: clamp(next.y, -maximumY, maximumY), + }; + }; + + const zoomAt = (nextScale: number, point?: PointerPoint) => { + const surface = surfaceRef.current; + const current = transformRef.current; + const scale = clamp(nextScale, MIN_SCALE, MAX_SCALE); + if (scale <= MIN_SCALE || !surface) { + commitTransform(DEFAULT_TRANSFORM); + return; + } + + const bounds = surface.getBoundingClientRect(); + const center = { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + }; + const anchor = point ?? center; + const imagePoint = { + x: (anchor.x - center.x - current.x) / current.scale, + y: (anchor.y - center.y - current.y) / current.scale, + }; + commitTransform(constrainTransform({ + scale, + x: anchor.x - center.x - imagePoint.x * scale, + y: anchor.y - center.y - imagePoint.y * scale, + })); + }; + + const zoomBy = (amount: number) => { + zoomAt(transformRef.current.scale + amount); + }; + + useLayoutEffect(() => { + window.clearTimeout(retryTimerRef.current); + pointersRef.current.clear(); + gestureStartRef.current = null; + pinchStartRef.current = null; + gestureAxisRef.current = null; + pinchedRef.current = false; + lastTapRef.current = null; + setNativeFallbackId(null); + setDragging(false); + commitTransform(DEFAULT_TRANSFORM); + setLoadState({ id: image.id, attempt: 0, loaded: false, failed: false }); + }, [image.id]); + + useEffect(() => { + const previouslyFocused = document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + const root = document.getElementById("root"); + const rootWasInert = root?.inert ?? false; + const htmlOverflow = document.documentElement.style.overflow; + const bodyOverflow = document.body.style.overflow; + root && (root.inert = true); + document.documentElement.style.overflow = "hidden"; + document.body.style.overflow = "hidden"; + const frame = window.requestAnimationFrame(() => { + closeButtonRef.current?.focus({ preventScroll: true }); + }); + + return () => { + window.cancelAnimationFrame(frame); + if (root) root.inert = rootWasInert; + document.documentElement.style.overflow = htmlOverflow; + document.body.style.overflow = bodyOverflow; + if (previouslyFocused?.isConnected) previouslyFocused.focus({ preventScroll: true }); + }; + }, []); + + useEffect(() => { + const updateFullscreenState = () => { + setFullscreen(document.fullscreenElement === dialogRef.current); + }; + setFullscreenAvailable(Boolean( + document.fullscreenEnabled && dialogRef.current?.requestFullscreen, + )); + updateFullscreenState(); + document.addEventListener("fullscreenchange", updateFullscreenState); + return () => { + document.removeEventListener("fullscreenchange", updateFullscreenState); + if (document.fullscreenElement === dialogRef.current) { + void document.exitFullscreen().catch(() => undefined); + } + }; + }, []); + + const toggleFullscreen = async () => { + try { + if (document.fullscreenElement === dialogRef.current) { + await document.exitFullscreen(); + } else { + await dialogRef.current?.requestFullscreen(); + } + } catch (error) { + console.warn("Pixhelf could not change fullscreen mode", error); + } + }; + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Tab") { + const focusable = Array.from( + dialogRef.current?.querySelectorAll( + 'button:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])', + ) ?? [], + ).filter((element) => !element.hasAttribute("inert")); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + return; + } + if (event.metaKey || event.ctrlKey || event.altKey) return; + switch (event.key) { + case "Escape": + if (document.fullscreenElement === dialogRef.current) return; + event.preventDefault(); + onClose(); + break; + case "ArrowLeft": + if (canPrevious) { + event.preventDefault(); + onNavigate(-1); + } + break; + case "ArrowRight": + if (canNext) { + event.preventDefault(); + onNavigate(1); + } + break; + case "+": + case "=": + event.preventDefault(); + zoomBy(0.5); + break; + case "-": + event.preventDefault(); + zoomBy(-0.5); + break; + case "0": + event.preventDefault(); + commitTransform(DEFAULT_TRANSFORM); + break; + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [canNext, canPrevious, onClose, onNavigate]); + + useEffect(() => { + if (!shouldPreloadViewerImages()) return; + const next = images[activeIndex + 1]; + const previous = images[activeIndex - 1]; + if (next) preloadOriginalImage(next); + if (previous) preloadOriginalImage(previous); + + if (!currentLoadState.loaded) return; + const timer = window.setTimeout(() => { + const secondNext = images[activeIndex + 2]; + if (secondNext) preloadOriginalImage(secondNext); + }, SECOND_NEIGHBOR_PRELOAD_DELAY_MS); + return () => window.clearTimeout(timer); + }, [activeIndex, currentLoadState.loaded, images]); + + useEffect(() => () => window.clearTimeout(retryTimerRef.current), []); + + const handleOriginalError = () => { + if (currentLoadState.attempt === 0) { + window.clearTimeout(retryTimerRef.current); + retryTimerRef.current = window.setTimeout(() => { + setLoadState({ id: image.id, attempt: 1, loaded: false, failed: false }); + }, 900); + return; + } + setLoadState({ + id: image.id, + attempt: currentLoadState.attempt, + loaded: false, + failed: true, + }); + }; + + const retryOriginal = () => { + window.clearTimeout(retryTimerRef.current); + setLoadState({ + id: image.id, + attempt: currentLoadState.attempt + 1, + loaded: false, + failed: false, + }); + }; + + useEffect(() => { + if (!useBitmapRenderer) return; + const controller = new AbortController(); + let bitmap: ImageBitmap | null = null; + let disposed = false; + + const renderOriginal = async () => { + try { + const response = await fetch(fullSource, { + signal: controller.signal, + cache: currentLoadState.attempt ? "reload" : "force-cache", + credentials: "same-origin", + }); + if (!response.ok) throw new Error(`original request failed: ${response.status}`); + const blob = await response.blob(); + if (!blob.size) throw new Error("original response is empty"); + + const sourceScale = Math.min( + 1, + MAX_RENDER_EDGE / Math.max(1, image.width, image.height), + ); + const resizeWidth = Math.max(1, Math.round(image.width * sourceScale)); + const resizeHeight = Math.max(1, Math.round(image.height * sourceScale)); + try { + bitmap = await createImageBitmap(blob, { + imageOrientation: "from-image", + resizeWidth, + resizeHeight, + resizeQuality: "high", + }); + } catch { + try { + // Some WebKit versions decode the format but reject resize options. + bitmap = await createImageBitmap(blob, { imageOrientation: "from-image" }); + } catch { + // The native image element remains a compatibility fallback for browsers + // whose createImageBitmap implementation supports fewer image variants. + if (!disposed) setNativeFallbackId(image.id); + return; + } + } + if (disposed) { + bitmap.close(); + bitmap = null; + return; + } + + const bitmapScale = Math.min( + 1, + MAX_RENDER_EDGE / Math.max(1, bitmap.width, bitmap.height), + ); + const renderWidth = Math.max(1, Math.round(bitmap.width * bitmapScale)); + const renderHeight = Math.max(1, Math.round(bitmap.height * bitmapScale)); + const canvas = canvasRef.current; + const context = canvas?.getContext("2d", { alpha: true }); + if (!canvas || !context) throw new Error("canvas renderer is unavailable"); + canvas.width = renderWidth; + canvas.height = renderHeight; + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + context.clearRect(0, 0, renderWidth, renderHeight); + context.drawImage(bitmap, 0, 0, renderWidth, renderHeight); + bitmap.close(); + bitmap = null; + + window.clearTimeout(retryTimerRef.current); + setLoadState({ + id: image.id, + attempt: currentLoadState.attempt, + loaded: true, + failed: false, + }); + } catch (error) { + if (disposed || controller.signal.aborted) return; + console.warn("Pixhelf could not render the original image", error); + handleOriginalError(); + } + }; + + void renderOriginal(); + return () => { + disposed = true; + controller.abort(); + bitmap?.close(); + }; + }, [fullSource, image.id, image.width, image.height, useBitmapRenderer]); + + const beginContact = (id: number, point: PointerPoint, pointerType: string) => { + if (pointerType !== "mouse") lastTouchAtRef.current = performance.now(); + pointersRef.current.set(id, point); + setDragging(true); + + if (pointersRef.current.size === 1) { + gestureStartRef.current = { + point, + transform: transformRef.current, + startedAt: performance.now(), + pointerType, + }; + gestureAxisRef.current = null; + } else if (pointersRef.current.size === 2) { + const [first, second] = Array.from(pointersRef.current.values()); + pinchedRef.current = true; + lastTapRef.current = null; + pinchStartRef.current = { + distance: Math.max(1, pointDistance(first, second)), + midpoint: pointMidpoint(first, second), + transform: transformRef.current, + }; + } + }; + + const moveContact = (id: number, point: PointerPoint) => { + if (!pointersRef.current.has(id)) return; + pointersRef.current.set(id, point); + + if (pointersRef.current.size >= 2 && pinchStartRef.current) { + const [first, second] = Array.from(pointersRef.current.values()); + const start = pinchStartRef.current; + const midpoint = pointMidpoint(first, second); + const scale = clamp( + start.transform.scale * pointDistance(first, second) / start.distance, + MIN_SCALE, + MAX_SCALE, + ); + const surface = surfaceRef.current; + if (!surface) return; + const bounds = surface.getBoundingClientRect(); + const center = { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + }; + const imagePoint = { + x: (start.midpoint.x - center.x - start.transform.x) / start.transform.scale, + y: (start.midpoint.y - center.y - start.transform.y) / start.transform.scale, + }; + commitTransform(constrainTransform({ + scale, + x: midpoint.x - center.x - imagePoint.x * scale, + y: midpoint.y - center.y - imagePoint.y * scale, + })); + return; + } + + const start = gestureStartRef.current; + if (!start || pointersRef.current.size !== 1) return; + const deltaX = point.x - start.point.x; + const deltaY = point.y - start.point.y; + if (!gestureAxisRef.current && Math.hypot(deltaX, deltaY) > 7) { + gestureAxisRef.current = Math.abs(deltaX) >= Math.abs(deltaY) ? "x" : "y"; + } + + if (start.transform.scale > MIN_SCALE) { + commitTransform(constrainTransform({ + ...start.transform, + x: start.transform.x + deltaX, + y: start.transform.y + deltaY, + })); + return; + } + + const surface = surfaceRef.current; + if (gestureAxisRef.current === "x") { + commitTransform({ + scale: 1, + x: rubberBand(deltaX, Math.max(90, (surface?.clientWidth ?? 360) * 0.32)), + y: 0, + }); + } else if (gestureAxisRef.current === "y") { + commitTransform({ + scale: 1, + x: 0, + y: rubberBand(deltaY, Math.max(80, (surface?.clientHeight ?? 640) * 0.2)), + }); + } + }; + + const handleTouchTap = (point: PointerPoint) => { + const now = performance.now(); + const lastTap = lastTapRef.current; + if ( + lastTap && + now - lastTap.at <= DOUBLE_TAP_DELAY_MS && + pointDistance(lastTap.point, point) < 32 + ) { + lastTapRef.current = null; + zoomAt( + transformRef.current.scale > MIN_SCALE ? MIN_SCALE : DOUBLE_TAP_SCALE, + point, + ); + return; + } + lastTapRef.current = { at: now, point }; + }; + + const finishContact = ( + id: number, + point: PointerPoint, + pointerType: string, + ) => { + const pointerCount = pointersRef.current.size; + pointersRef.current.delete(id); + + if (pointerCount > 1) { + const remaining = pointersRef.current.values().next().value as PointerPoint | undefined; + pinchStartRef.current = null; + if (remaining) { + gestureStartRef.current = { + point: remaining, + transform: transformRef.current, + startedAt: performance.now(), + pointerType, + }; + } + return; + } + + setDragging(false); + const start = gestureStartRef.current; + gestureStartRef.current = null; + pinchStartRef.current = null; + const wasPinched = pinchedRef.current; + pinchedRef.current = false; + if (wasPinched) { + commitTransform(constrainTransform(transformRef.current)); + return; + } + if (!start) return; + + const deltaX = point.x - start.point.x; + const deltaY = point.y - start.point.y; + const elapsed = Math.max(1, performance.now() - start.startedAt); + const isTap = Math.hypot(deltaX, deltaY) < 10 && elapsed < 320; + + if (start.transform.scale > MIN_SCALE) { + if (isTap && start.pointerType !== "mouse") { + handleTouchTap(point); + return; + } + commitTransform(constrainTransform(transformRef.current)); + return; + } + + const surface = surfaceRef.current; + const horizontalThreshold = Math.max(54, (surface?.clientWidth ?? 360) * 0.11); + const horizontalFlick = Math.abs(deltaX) > 30 && Math.abs(deltaX) / elapsed > 0.48; + const verticalThreshold = Math.max(84, (surface?.clientHeight ?? 640) * 0.12); + const verticalFlick = deltaY > 38 && deltaY / elapsed > 0.52; + + if ( + gestureAxisRef.current === "x" && + Math.abs(deltaX) > Math.abs(deltaY) && + (Math.abs(deltaX) >= horizontalThreshold || horizontalFlick) + ) { + if (deltaX < 0 && canNext) { + commitTransform(DEFAULT_TRANSFORM); + onNavigate(1); + return; + } + if (deltaX > 0 && canPrevious) { + commitTransform(DEFAULT_TRANSFORM); + onNavigate(-1); + return; + } + } + if ( + gestureAxisRef.current === "y" && + deltaY > Math.abs(deltaX) && + (deltaY >= verticalThreshold || verticalFlick) + ) { + onClose(); + return; + } + + commitTransform(DEFAULT_TRANSFORM); + if (isTap && start.pointerType !== "mouse") { + handleTouchTap(point); + } + }; + + const cancelContacts = () => { + pointersRef.current.clear(); + gestureStartRef.current = null; + pinchStartRef.current = null; + gestureAxisRef.current = null; + pinchedRef.current = false; + setDragging(false); + commitTransform(constrainTransform(transformRef.current)); + }; + + const beginPointer = (event: JSX.TargetedPointerEvent) => { + // Touch input is handled by non-passive native TouchEvent listeners below. Keeping + // it out of this path prevents browsers that emit both event families from applying + // every movement twice. + if (event.pointerType === "touch") return; + if (event.pointerType === "mouse" && event.button !== 0) return; + event.preventDefault(); + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // Some pen/browser combinations do not allow capture here. + } + beginContact( + event.pointerId, + { x: event.clientX, y: event.clientY }, + event.pointerType, + ); + }; + + const movePointer = (event: JSX.TargetedPointerEvent) => { + if (event.pointerType === "touch") return; + if (!pointersRef.current.has(event.pointerId)) return; + event.preventDefault(); + moveContact(event.pointerId, { x: event.clientX, y: event.clientY }); + }; + + const finishPointer = (event: JSX.TargetedPointerEvent) => { + if (event.pointerType === "touch") return; + finishContact( + event.pointerId, + { x: event.clientX, y: event.clientY }, + event.pointerType, + ); + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // The browser may have released capture already. + } + }; + + const cancelPointer = (event: JSX.TargetedPointerEvent) => { + if (event.pointerType !== "touch") cancelContacts(); + }; + + gestureHandlersRef.current = { + begin: beginContact, + move: moveContact, + finish: finishContact, + cancel: cancelContacts, + }; + + useEffect(() => { + const surface = surfaceRef.current; + if (!surface) return; + + const touchPoint = (touch: Touch): PointerPoint => ({ + x: touch.clientX, + y: touch.clientY, + }); + const handleTouchStart = (event: TouchEvent) => { + event.preventDefault(); + for (const touch of Array.from(event.changedTouches)) { + gestureHandlersRef.current?.begin(touch.identifier, touchPoint(touch), "touch"); + } + }; + const handleTouchMove = (event: TouchEvent) => { + event.preventDefault(); + for (const touch of Array.from(event.changedTouches)) { + gestureHandlersRef.current?.move(touch.identifier, touchPoint(touch)); + } + }; + const handleTouchEnd = (event: TouchEvent) => { + event.preventDefault(); + for (const touch of Array.from(event.changedTouches)) { + gestureHandlersRef.current?.finish(touch.identifier, touchPoint(touch), "touch"); + } + }; + const handleTouchCancel = (event: TouchEvent) => { + event.preventDefault(); + gestureHandlersRef.current?.cancel(); + }; + + surface.addEventListener("touchstart", handleTouchStart, { passive: false }); + surface.addEventListener("touchmove", handleTouchMove, { passive: false }); + surface.addEventListener("touchend", handleTouchEnd, { passive: false }); + surface.addEventListener("touchcancel", handleTouchCancel, { passive: false }); + return () => { + surface.removeEventListener("touchstart", handleTouchStart); + surface.removeEventListener("touchmove", handleTouchMove); + surface.removeEventListener("touchend", handleTouchEnd); + surface.removeEventListener("touchcancel", handleTouchCancel); + }; + }, []); + + const handleWheel = (event: JSX.TargetedWheelEvent) => { + event.preventDefault(); + const factor = Math.exp(-event.deltaY * 0.002); + zoomAt(transformRef.current.scale * factor, { x: event.clientX, y: event.clientY }); + }; + + const displayPosition = Math.min(total, activeIndex + 1); + const zoomPercent = Math.round(transform.scale * 100); + const viewerStyle = { + "--viewer-dismiss-progress": String( + transform.scale === 1 + ? clamp(Math.max(0, transform.y) / Math.max(1, surfaceRef.current?.clientHeight ?? 640), 0, 0.45) + : 0, + ), + } as CSSProperties; + const compactViewport = viewport.width <= 720; + const availableWidth = Math.max(1, viewport.width - (compactViewport ? 12 : 144)); + const availableHeight = Math.max(1, viewport.height - (compactViewport ? 66 : 82)); + const imageRatio = image.width / Math.max(1, image.height); + const mediaWidth = Math.min(availableWidth, availableHeight * imageRatio); + const mediaHeight = mediaWidth / imageRatio; + const mediaStyle = { + width: `${mediaWidth}px`, + height: `${mediaHeight}px`, + aspectRatio: `${image.width} / ${image.height}`, + transform: `translate3d(${transform.x}px, ${transform.y}px, 0) scale(${transform.scale})`, + } as CSSProperties; + const viewer = ( +
MIN_SCALE} + data-dragging={dragging} + data-renderer={useBitmapRenderer ? "bitmap" : "native"} + style={viewerStyle} + > +