diff --git a/Cargo.lock b/Cargo.lock index 9bdd2c3..0d3a8ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -618,7 +618,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pixhelf" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 6661d9a..99ae384 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pixhelf" -version = "0.1.5" +version = "0.1.6" edition = "2024" build = "build.rs" diff --git a/README.md b/README.md index 8f3b0ef..f73134d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ HTTP 服务会立即开放,并优先生成首批 60 张缩略图。每张图 首屏图库数据与压缩后的关键样式会随 HTML 一并下发,首张图片会提前加载。JS 使用内容版本化的长期缓存,API 使用 ETag 协商缓存,并按浏览器能力启用 Brotli 或 gzip 压缩。 -缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的单一版本化目录,原图不会被修改。查看器拥有独立于瀑布流可见性的资源队列:提前解码切图窗口内的缩略图并预取相邻原图,切换目标时立即显示对应缩略图,原图下载且解码完成后再无闪烁替换;桌面端会直接复用已就绪的常规原图,移动端切图首帧先使用与视口及 DPR 匹配的高质量位图,当前图片稳定后在后台自动加载真实原图,放大时立即切换,复位后回到更适合屏幕采样的视口位图,连续切图会取消过期升级任务。只有超过安全边界的极端大图才绘制到 GPU 安全画布。查看器支持键盘方向键、鼠标前进/后退侧键和滚轮缩放,以及移动端滑动、双击和双指缩放;在适应屏幕状态下继续向下滚动或上滑,可从图片首屏连续进入详情第二屏。关闭查看器时会在瀑布流布局稳定后把当前图片动画送回对应卡片,即使期间发生续载或窗口尺寸变化也会重新锁定落点。窗口宽度引发瀑布流重排时,会按图片 ID 保持当前焦点或视口中心图片的相对位置。缓存目录必须位于图库目录之外。 +缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的单一版本化目录,原图不会被修改。查看器拥有独立于瀑布流可见性的资源队列:已经解码的显示资源会直接复用,不再回退到缩略图;桌面端使用原图,移动端在原图像素不超过屏幕目标时也直接使用原图,大图则在当前图片稳定后从中性色缩略图升级到与视口及 DPR 匹配的高质量位图。全分辨率大图只在放大时按需加载,复位后回到更适合屏幕采样的视口位图,连续切图会取消过期升级任务。缩略图不做额外亮度或饱和度处理,升级时由高质量图层覆盖淡入,避免透明度叠加造成画面先暗后亮。只有超过安全边界的极端大图才绘制到 GPU 安全画布。查看器支持键盘方向键、鼠标前进/后退侧键和滚轮缩放,以及移动端滑动、双击和双指缩放;在适应屏幕状态下继续向下滚动或上滑,可从图片首屏连续进入详情第二屏。关闭查看器时会在瀑布流布局稳定后把当前图片动画送回对应卡片,即使期间发生续载或窗口尺寸变化也会重新锁定落点。窗口宽度引发瀑布流重排时,会按图片 ID 保持当前焦点或视口中心图片的相对位置。缓存目录必须位于图库目录之外。 ## 构建 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 287d5a7..0b63a62 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "pixhelf-frontend", - "version": "0.1.5", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pixhelf-frontend", - "version": "0.1.5", + "version": "0.1.6", "dependencies": { "preact": "^10.27.1" }, diff --git a/frontend/package.json b/frontend/package.json index 68a452f..bb4cee9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "pixhelf-frontend", "private": true, - "version": "0.1.5", + "version": "0.1.6", "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 45c98ba..4fa1b5b 100644 --- a/frontend/scripts/visual-check.mjs +++ b/frontend/scripts/visual-check.mjs @@ -353,12 +353,6 @@ try { await page.waitForFunction(() => document.querySelector(".image-viewer")?.getAttribute("data-full-loaded") === "true" , undefined, { timeout: 60_000 }); - if (target.name === "mobile") { - await page.waitForFunction(() => - document.querySelector(".image-viewer") - ?.getAttribute("data-native-original-loaded") === "true" - , undefined, { timeout: 60_000 }); - } await page.waitForFunction(() => { const originalPaths = performance.getEntriesByType("resource") .map((entry) => new URL(entry.name).pathname) @@ -370,11 +364,15 @@ try { await page.screenshot({ path: `/tmp/pixhelf-viewer-${target.name}.png` }); const viewer = await page.evaluate(() => { const overlay = document.querySelector(".image-viewer"); + const thumbnail = document.querySelector(".viewer-thumbnail"); const original = document.querySelector(".viewer-original"); const nativeOriginal = document.querySelector(".viewer-native-original"); - const media = document.querySelector(".viewer-media")?.getBoundingClientRect(); + const mediaElement = document.querySelector(".viewer-media"); + const media = mediaElement?.getBoundingClientRect(); const stage = document.querySelector(".viewer-stage")?.getBoundingClientRect(); const details = document.querySelector(".viewer-details-page")?.getBoundingClientRect(); + const thumbnailStyle = thumbnail ? getComputedStyle(thumbnail) : null; + const originalStyle = original ? getComputedStyle(original) : null; const resourcePaths = performance.getEntriesByType("resource") .map((entry) => new URL(entry.name).pathname); const originalSource = original instanceof HTMLImageElement @@ -396,11 +394,17 @@ try { nativeOriginalVisible: nativeOriginal ? Number.parseFloat(getComputedStyle(nativeOriginal).opacity) : 0, - mediaWillChange: document.querySelector(".viewer-media") - ? getComputedStyle(document.querySelector(".viewer-media")).willChange - : "", + mediaWillChange: mediaElement ? getComputedStyle(mediaElement).willChange : "", originalPath: originalSource ? new URL(originalSource, location.href).pathname : "", renderer: overlay?.getAttribute("data-renderer"), + sourceStrategy: overlay?.getAttribute("data-source-strategy") ?? "", + sourcePresentation: overlay?.getAttribute("data-source-presentation") ?? "", + thumbnailFilter: thumbnailStyle?.filter ?? "", + thumbnailTransform: thumbnailStyle?.transform ?? "", + thumbnailTransitionDelay: thumbnailStyle?.transitionDelay ?? "", + originalTransitionDuration: originalStyle?.transitionDuration ?? "", + gestureRenderer: overlay?.getAttribute("data-mobile-gesture-renderer") ?? "", + originalPolicy: overlay?.getAttribute("data-mobile-original-policy") ?? "", scrollMode: overlay?.getAttribute("data-scroll-mode"), renderedPixels: original instanceof HTMLCanvasElement ? [original.width, original.height] @@ -473,17 +477,28 @@ try { await page.waitForFunction((imageId) => document.querySelector(".image-viewer")?.getAttribute("data-image-id") === imageId , sideButtonStart.activeId); - const sideButtonNavigation = await page.evaluate(({ start, forward, back }) => ({ - forward, - back, - returnedId: document.querySelector(".image-viewer")?.getAttribute("data-image-id") ?? "", - enabled: document.querySelector(".image-viewer") - ?.getAttribute("data-mouse-side-navigation") === "true", - historyPreserved: history.length === start.historyLength && location.href === start.href, - }), { start: sideButtonStart, forward: sideForwardEvents, back: sideBackEvents }); + await page.evaluate(() => new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)) + )); + const sideButtonNavigation = await page.evaluate(({ start, forward, back }) => { + const overlay = document.querySelector(".image-viewer"); + return { + forward, + back, + returnedId: overlay?.getAttribute("data-image-id") ?? "", + returnedSource: overlay?.getAttribute("data-display-source") ?? "", + returnedPresentation: overlay?.getAttribute("data-source-presentation") ?? "", + returnedLoaded: overlay?.getAttribute("data-full-loaded") ?? "false", + enabled: overlay?.getAttribute("data-mouse-side-navigation") === "true", + historyPreserved: history.length === start.historyLength && location.href === start.href, + }; + }, { start: sideButtonStart, forward: sideForwardEvents, back: sideBackEvents }); if ( !sideButtonStart.nextId || sideButtonNavigation.returnedId !== sideButtonStart.activeId || + sideButtonNavigation.returnedPresentation !== "direct" || + sideButtonNavigation.returnedLoaded !== "true" || + !["original", "viewport-bitmap"].includes(sideButtonNavigation.returnedSource) || !sideButtonNavigation.enabled || !sideButtonNavigation.historyPreserved || !Object.values(sideButtonNavigation.forward).every(Boolean) || @@ -531,9 +546,9 @@ try { switchPerformance.currentId !== switchPerformance.expectedId || switchPerformance.dispatchDuration > 80 || switchPerformance.firstFrameDuration > 100 || - switchPerformance.firstFrameSource !== ( - target.name === "mobile" ? "viewport-bitmap" : "original" - ) || + (target.name === "mobile" + ? !["thumbnail", "viewport-bitmap"].includes(switchPerformance.firstFrameSource) + : switchPerformance.firstFrameSource !== "original") || !switchPerformance.focusedOnViewer || switchPerformance.visibleControlFocusRings !== 0 || switchPerformance.mediaAnimations !== 0 @@ -591,12 +606,6 @@ try { await page.waitForFunction(() => document.querySelector(".image-viewer")?.getAttribute("data-full-loaded") === "true" , undefined, { timeout: 60_000 }); - if (target.name === "mobile") { - await page.waitForFunction(() => - document.querySelector(".image-viewer") - ?.getAttribute("data-native-original-loaded") === "true" - , undefined, { timeout: 60_000 }); - } rapidSwitch.upgradedSource = await page.locator(".image-viewer").getAttribute("data-display-source"); Object.assign(viewer, { rapidSwitch }); if ( @@ -834,6 +843,28 @@ try { ); viewer.swipeMotionCleaned = true; + const shortSwipeStartName = await page.locator(".image-viewer") + .getAttribute("data-image-name"); + await dispatchTouch("touchStart", [{ id: 32, x: 240, y: 420 }]); + await page.waitForTimeout(100); + await dispatchTouch("touchMove", [{ id: 32, x: 200, y: 420 }]); + await page.waitForTimeout(100); + await dispatchTouch("touchEnd", []); + await page.waitForFunction((title) => + document.querySelector(".image-viewer")?.getAttribute("data-image-name") !== title + , shortSwipeStartName); + const shortSwipeEndName = await page.locator(".image-viewer") + .getAttribute("data-image-name"); + viewer.shortSwipeNavigation = { + distance: 40, + changed: shortSwipeEndName !== shortSwipeStartName, + imageName: shortSwipeEndName, + }; + await page.waitForFunction(() => + !document.querySelector(".viewer-swipe-outgoing") + && !document.querySelector(".image-viewer")?.hasAttribute("data-swipe-direction") + ); + await dispatchTouch("touchStart", [{ id: 35, x: 195, y: 500 }]); await page.waitForTimeout(32); await dispatchTouch("touchMove", [{ id: 35, x: 195, y: 350 }]); @@ -1546,6 +1577,15 @@ try { viewer.fullLoaded !== "true" || !viewer.originalPath.endsWith("/original") || !["native", "safe-canvas", "viewport-bitmap"].includes(viewer.renderer) || + viewer.sourceStrategy !== ( + target.name === "mobile" ? "viewport-upgrade" : "direct-original" + ) || + viewer.thumbnailFilter !== "none" || + viewer.thumbnailTransform !== "none" || + (viewer.sourcePresentation === "upgrade" && ( + Number.parseFloat(viewer.thumbnailTransitionDelay) < .15 || + Number.parseFloat(viewer.originalTransitionDuration) < .15 + )) || viewer.renderedPixels.some((size) => size <= 0) || (viewer.renderer === "safe-canvas" && viewer.renderedPixels.some((size) => size > 4096)) || (viewer.renderer === "viewport-bitmap" && viewer.renderedPixels.some((size) => size > 2048)) || @@ -1582,9 +1622,9 @@ try { viewer.keyboardFocus.visibleControlFocusRings !== 0 || !viewer.switchPerformance?.focusedOnViewer || viewer.switchPerformance.visibleControlFocusRings !== 0 || - viewer.switchPerformance.firstFrameSource !== ( - target.name === "mobile" ? "viewport-bitmap" : "original" - ) || + (target.name === "mobile" + ? !["thumbnail", "viewport-bitmap"].includes(viewer.switchPerformance.firstFrameSource) + : viewer.switchPerformance.firstFrameSource !== "original") || viewer.switchPerformance.mediaAnimations !== 0 || viewer.switchPerformance.dispatchDuration > 80 || viewer.switchPerformance.firstFrameDuration > 100 || @@ -1606,13 +1646,15 @@ try { !viewer.returnWheelHandoffLocked )) || (target.name === "mobile" && ( - viewer.nativeOriginalLoaded !== "true" || + viewer.nativeOriginalLoaded !== "false" || viewer.nativeOriginalActive !== "false" || - viewer.nativeOriginalCount !== 1 || - !viewer.nativeOriginalPath.endsWith("/original") || + viewer.nativeOriginalCount !== 0 || + viewer.nativeOriginalPath !== "" || viewer.nativeOriginalVisible > .01 || - viewer.nativeOriginalPixels.some((size, index) => size <= viewer.renderedPixels[index]) || + viewer.nativeOriginalPixels.some((size) => size !== 0) || viewer.displaySource !== "viewport-bitmap" || + viewer.gestureRenderer !== "raf-dom" || + viewer.originalPolicy !== "zoom-only" || viewer.mediaWillChange !== "auto" || !viewer.pinchZoomed || viewer.pinchScale < 1.5 || @@ -1628,6 +1670,8 @@ try { viewer.nativeOriginalAfterReset.settledWillChange !== "auto" || !viewer.swipeNavigation || viewer.swipeNavigation === viewer.keyboardNavigation || + !viewer.shortSwipeNavigation?.changed || + viewer.shortSwipeNavigation.distance !== 40 || viewer.swipeDragTracking > -190 || viewer.swipeDragTracking < -225 || viewer.swipeDragWillChange !== "transform" || @@ -1644,7 +1688,7 @@ try { !viewer.detailsFollowedTouch || !viewer.detailsReachedBySwipe || viewer.pageAfterDetails !== "details" || - viewer.detailsName !== viewer.swipeNavigation || + viewer.detailsName !== viewer.shortSwipeNavigation.imageName || viewer.detailsHeading !== "图片详情" || !viewer.stageScrolledAway || !viewer.detailsVisible || diff --git a/frontend/src/ImageViewer.tsx b/frontend/src/ImageViewer.tsx index 36397e1..537c5b7 100644 --- a/frontend/src/ImageViewer.tsx +++ b/frontend/src/ImageViewer.tsx @@ -15,12 +15,14 @@ import { } from "./icons"; import type { GalleryImage } from "./types"; import { + canPreloadViewerNeighbors, getViewerOriginalAsset, getViewerOriginalStatus, + getReadyViewerViewportRenderAsset, getViewerThumbnailStatus, getViewerViewportRenderAsset, - getViewerViewportRenderStatus, prepareViewerImages, + supportsViewerViewportBitmaps, viewerOriginalUrl, viewerThumbnailUrl, } from "./viewerAssets"; @@ -36,7 +38,13 @@ const MAX_NATIVE_IMAGE_EDGE = 12_288; const MOBILE_VIEWPORT_RENDER_EDGE = 2048; const MOBILE_SWIPE_MOTION_MS = 170; const MOBILE_SWIPE_CLEANUP_MS = MOBILE_SWIPE_MOTION_MS + 50; -const MOBILE_ORIGINAL_UPGRADE_DELAY_MS = MOBILE_SWIPE_CLEANUP_MS + 40; +const MOBILE_RENDER_SETTLE_MS = MOBILE_SWIPE_CLEANUP_MS + 16; +const MOBILE_NEIGHBOR_PRELOAD_DELAY_MS = 120; +const TOUCH_SWIPE_MIN_DISTANCE_PX = 36; +const TOUCH_SWIPE_MAX_DISTANCE_PX = 56; +const TOUCH_SWIPE_DISTANCE_RATIO = 0.08; +const TOUCH_SWIPE_FLICK_MIN_DISTANCE_PX = 20; +const TOUCH_SWIPE_FLICK_VELOCITY = 0.32; type ViewerTransform = { scale: number; @@ -67,11 +75,23 @@ type PinchStart = { transform: ViewerTransform; }; -type OriginalLoadState = { +type ViewerSourcePresentation = "direct" | "upgrade"; + +type ViewerSourceStrategy = "viewport-upgrade" | "bounded-canvas" | "direct-original"; + +type ViewerSourceState = { id: string; attempt: number; loaded: boolean; failed: boolean; + presentation: ViewerSourcePresentation; +}; + +type ViewerDisplayState = { + sourceLoaded: boolean; + thumbnailLoaded: boolean; + viewportBitmapRenderer: boolean; + nativeOriginalActive: boolean; }; type ThumbnailLoadState = { @@ -97,6 +117,14 @@ type ViewportSize = { height: number; }; +type GestureGeometry = { + surfaceWidth: number; + surfaceHeight: number; + mediaWidth: number; + mediaHeight: number; + surfaceBounds: DOMRect; +}; + type GestureHandlers = { begin: (id: number, point: PointerPoint, pointerType: string) => void; move: (id: number, point: PointerPoint) => void; @@ -104,6 +132,11 @@ type GestureHandlers = { cancel: () => void; }; +type IdleScheduler = Window & { + requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number; + cancelIdleCallback?: (handle: number) => void; +}; + const DEFAULT_TRANSFORM: ViewerTransform = { scale: 1, x: 0, y: 0 }; function readViewportSize(): ViewportSize { @@ -194,6 +227,76 @@ function viewportRenderDimensions( }; } +function selectViewerSourceStrategy( + image: GalleryImage, + renderSize: ViewportSize, + viewportBitmapsSupported: boolean, +): ViewerSourceStrategy { + const sourceFitsViewport = image.width <= renderSize.width + && image.height <= renderSize.height; + if (viewportBitmapsSupported && !sourceFitsViewport) return "viewport-upgrade"; + if (Math.max(image.width, image.height) > MAX_NATIVE_IMAGE_EDGE) return "bounded-canvas"; + return "direct-original"; +} + +function isDirectOriginalReady( + image: GalleryImage, + strategy: ViewerSourceStrategy, + displayedSources: ReadonlySet, +): boolean { + return strategy === "direct-original" && ( + displayedSources.has(viewerOriginalUrl(image)) + || getViewerOriginalStatus(image) === "ready" + ); +} + +function initialViewerSourceState(id: string, ready: boolean): ViewerSourceState { + return { + id, + attempt: 0, + loaded: ready, + failed: false, + presentation: ready ? "direct" : "upgrade", + }; +} + +function pendingViewerSourceState( + id: string, + attempt: number, + failed = false, +): ViewerSourceState { + return { id, attempt, loaded: false, failed, presentation: "upgrade" }; +} + +function resolveViewerDisplaySource({ + sourceLoaded, + thumbnailLoaded, + viewportBitmapRenderer, + nativeOriginalActive, +}: ViewerDisplayState): "original" | "viewport-bitmap" | "thumbnail" | "placeholder" { + if (nativeOriginalActive) return "original"; + if (sourceLoaded) return viewportBitmapRenderer ? "viewport-bitmap" : "original"; + return thumbnailLoaded ? "thumbnail" : "placeholder"; +} + +function drawViewerCanvas( + canvas: HTMLCanvasElement, + source: CanvasImageSource, + width: number, + height: number, + sourceKey: string, +): void { + const context = canvas.getContext("2d", { alpha: true }); + if (!context) throw new Error("canvas renderer is unavailable"); + canvas.width = width; + canvas.height = height; + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + context.clearRect(0, 0, width, height); + context.drawImage(source, 0, 0, width, height); + canvas.dataset.renderedSource = sourceKey; +} + export function ImageViewer({ images, activeIndex, @@ -214,12 +317,17 @@ export function ImageViewer({ const image = images[activeIndex]!; const viewport = useViewportSize(); const compactViewport = viewport.width <= 720; - const useViewportBitmapRenderer = compactViewport && typeof createImageBitmap === "function"; - const useSafeCanvasRenderer = !useViewportBitmapRenderer - && Math.max(image.width, image.height) > MAX_NATIVE_IMAGE_EDGE; - const useCanvasRenderer = useViewportBitmapRenderer || useSafeCanvasRenderer; const mediaDimensions = viewerMediaDimensions(image, viewport); const viewportRender = viewportRenderDimensions(image, viewport); + const viewportBitmapCapable = supportsViewerViewportBitmaps(); + const sourceStrategy = selectViewerSourceStrategy( + image, + viewportRender, + viewportBitmapCapable, + ); + const useViewportBitmapRenderer = sourceStrategy === "viewport-upgrade"; + const useSafeCanvasRenderer = sourceStrategy === "bounded-canvas"; + const useCanvasRenderer = sourceStrategy !== "direct-original"; const canPrevious = activeIndex > 0; const canNext = activeIndex < images.length - 1 || hasMore; const waitingForNext = loadingMore && activeIndex === images.length - 1; @@ -232,6 +340,7 @@ export function ImageViewer({ const pointersRef = useRef(new Map()); const gestureStartRef = useRef(null); const pinchStartRef = useRef(null); + const gestureGeometryRef = useRef(null); const gestureAxisRef = useRef<"x" | "y" | null>(null); const gestureModeRef = useRef(null); const pinchedRef = useRef(false); @@ -239,10 +348,13 @@ export function ImageViewer({ const lastTouchAtRef = useRef(0); const gestureHandlersRef = useRef(null); const retryTimerRef = useRef(0); + const sourceUpgradeTimerRef = useRef(0); const scrollTopRef = useRef(0); const scrollPageRef = useRef("image"); const wheelZoomBlockedUntilRef = useRef(0); const displayedOriginalsRef = useRef(new Set()); + const transformFrameRef = useRef(0); + const pendingTransformRef = useRef(null); const swipeCleanupTimerRef = useRef(0); const swipeOutgoingRef = useRef(null); const preparedSwipeSnapshotRef = useRef(null); @@ -261,42 +373,23 @@ export function ImageViewer({ loaded: getViewerThumbnailStatus(image) === "ready", failed: false, }); - const initialOriginalSource = viewerOriginalUrl(image); - const initialOriginalReady = useViewportBitmapRenderer - ? getViewerViewportRenderStatus( - image, - viewportRender.width, - viewportRender.height, - ) === "ready" - : !useSafeCanvasRenderer && ( - displayedOriginalsRef.current.has(initialOriginalSource) - || getViewerOriginalStatus(image) === "ready" - ); - const [loadState, setLoadState] = useState({ - id: image.id, - attempt: 0, - loaded: initialOriginalReady, - failed: false, - }); + const directOriginalReady = isDirectOriginalReady( + image, + sourceStrategy, + displayedOriginalsRef.current, + ); + const [loadState, setLoadState] = useState( + initialViewerSourceState(image.id, directOriginalReady), + ); const [fullResolutionState, setFullResolutionState] = useState({ id: image.id, requested: false, loaded: false, failed: false, }); - const fallbackOriginalReady = useViewportBitmapRenderer - ? getViewerViewportRenderStatus( - image, - viewportRender.width, - viewportRender.height, - ) === "ready" - : !useSafeCanvasRenderer && ( - displayedOriginalsRef.current.has(initialOriginalSource) - || getViewerOriginalStatus(image) === "ready" - ); const currentLoadState = loadState.id === image.id ? loadState - : { id: image.id, attempt: 0, loaded: fallbackOriginalReady, failed: false }; + : initialViewerSourceState(image.id, directOriginalReady); const currentFullResolution = fullResolutionState.id === image.id ? fullResolutionState : { id: image.id, requested: false, loaded: false, failed: false }; @@ -306,8 +399,103 @@ export function ImageViewer({ const thumbnailFailed = thumbnailState.id === image.id && thumbnailState.failed; const fullSource = viewerOriginalUrl(image, currentLoadState.attempt); - const commitTransform = (next: ViewerTransform) => { + const viewerIsBusy = (includeSwipe = true) => pointersRef.current.size > 0 + || (includeSwipe && Boolean(dialogRef.current?.hasAttribute("data-swipe-direction"))); + + const markOriginalReady = ( + imageId: string, + attempt: number, + presentation: ViewerSourcePresentation = "upgrade", + ) => { + window.clearTimeout(sourceUpgradeTimerRef.current); + const commit = () => { + sourceUpgradeTimerRef.current = 0; + if (dialogRef.current?.dataset.imageId !== imageId) return; + if (presentation === "upgrade" && viewerIsBusy()) { + sourceUpgradeTimerRef.current = window.setTimeout(commit, 48); + return; + } + setLoadState((current) => { + if (current.id === imageId && current.attempt !== attempt) return current; + if ( + current.id === imageId + && current.loaded + && (current.presentation === "direct" || presentation === "upgrade") + ) return current; + return { + id: imageId, + attempt, + loaded: true, + failed: false, + presentation, + }; + }); + }; + commit(); + }; + + const applyTransformToDom = (next: ViewerTransform) => { + const media = mediaRef.current; + if (media) { + media.style.transform = `translate3d(${next.x}px, ${next.y}px, 0) scale(${next.scale})`; + } + const viewer = dialogRef.current; + if (!viewer) return; + const surfaceHeight = gestureGeometryRef.current?.surfaceHeight + ?? surfaceRef.current?.clientHeight + ?? 640; + const dismissProgress = next.scale === MIN_SCALE + ? clamp(Math.max(0, next.y) / Math.max(1, surfaceHeight), 0, 0.45) + : 0; + viewer.style.setProperty("--viewer-dismiss-progress", String(dismissProgress)); + const zoomed = next.scale > MIN_SCALE; + viewer.dataset.zoomed = String(zoomed); + if (useViewportBitmapRenderer) { + const nativeActive = zoomed && viewer.dataset.nativeOriginalLoaded === "true"; + viewer.dataset.nativeOriginalActive = String(nativeActive); + viewer.dataset.displaySource = resolveViewerDisplaySource({ + sourceLoaded: viewer.dataset.fullLoaded === "true", + thumbnailLoaded: viewer.dataset.thumbnailLoaded === "true", + viewportBitmapRenderer: true, + nativeOriginalActive: nativeActive, + }); + } + }; + + const cancelTransformFrame = () => { + if (transformFrameRef.current) { + window.cancelAnimationFrame(transformFrameRef.current); + transformFrameRef.current = 0; + } + pendingTransformRef.current = null; + }; + + const flushStagedTransform = () => { + if (transformFrameRef.current) { + window.cancelAnimationFrame(transformFrameRef.current); + transformFrameRef.current = 0; + } + const pending = pendingTransformRef.current; + pendingTransformRef.current = null; + if (pending) applyTransformToDom(pending); + }; + + const stageTransform = (next: ViewerTransform) => { transformRef.current = next; + pendingTransformRef.current = next; + if (transformFrameRef.current) return; + transformFrameRef.current = window.requestAnimationFrame(() => { + transformFrameRef.current = 0; + const pending = pendingTransformRef.current; + pendingTransformRef.current = null; + if (pending) applyTransformToDom(pending); + }); + }; + + const commitTransform = (next: ViewerTransform) => { + cancelTransformFrame(); + transformRef.current = next; + applyTransformToDom(next); setTransform(next); }; @@ -335,15 +523,27 @@ export function ImageViewer({ syncScrollPage(viewer.scrollTop); }; - const constrainTransform = (next: ViewerTransform): ViewerTransform => { + const constrainTransform = ( + next: ViewerTransform, + geometry = gestureGeometryRef.current, + ): 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); + const surfaceWidth = geometry?.surfaceWidth ?? surface?.clientWidth; + const surfaceHeight = geometry?.surfaceHeight ?? surface?.clientHeight; + const mediaWidth = geometry?.mediaWidth ?? media?.offsetWidth; + const mediaHeight = geometry?.mediaHeight ?? media?.offsetHeight; + if ( + surfaceWidth === undefined + || surfaceHeight === undefined + || mediaWidth === undefined + || mediaHeight === undefined + ) return { scale, x: next.x, y: next.y }; + const maximumX = Math.max(0, (mediaWidth * scale - surfaceWidth) / 2); + const maximumY = Math.max(0, (mediaHeight * scale - surfaceHeight) / 2); return { scale, x: clamp(next.x, -maximumX, maximumX), @@ -531,9 +731,12 @@ export function ImageViewer({ useLayoutEffect(() => { window.clearTimeout(retryTimerRef.current); + window.clearTimeout(sourceUpgradeTimerRef.current); + sourceUpgradeTimerRef.current = 0; pointersRef.current.clear(); gestureStartRef.current = null; pinchStartRef.current = null; + gestureGeometryRef.current = null; gestureAxisRef.current = null; gestureModeRef.current = null; pinchedRef.current = false; @@ -547,21 +750,12 @@ export function ImageViewer({ loaded: getViewerThumbnailStatus(image) === "ready", failed: false, }); - setLoadState({ - id: image.id, - attempt: 0, - loaded: useViewportBitmapRenderer - ? getViewerViewportRenderStatus( - image, - viewportRender.width, - viewportRender.height, - ) === "ready" - : !useSafeCanvasRenderer && ( - displayedOriginalsRef.current.has(viewerOriginalUrl(image)) - || getViewerOriginalStatus(image) === "ready" - ), - failed: false, - }); + const originalReady = isDirectOriginalReady( + image, + sourceStrategy, + displayedOriginalsRef.current, + ); + setLoadState(initialViewerSourceState(image.id, originalReady)); setFullResolutionState({ id: image.id, requested: false, @@ -570,8 +764,7 @@ export function ImageViewer({ }); }, [ image.id, - useSafeCanvasRenderer, - useViewportBitmapRenderer, + sourceStrategy, viewportRender.height, viewportRender.width, ]); @@ -768,25 +961,75 @@ export function ImageViewer({ }, [canNext, canPrevious, onNavigate]); useEffect(() => { - prepareViewerImages(images, activeIndex); - if (!useViewportBitmapRenderer) return; - for (const offset of [0, 1, -1]) { - const candidate = images[activeIndex + offset]; - if (!candidate) continue; - if (offset !== 0 && getViewerOriginalStatus(candidate) === "idle") continue; - const render = viewportRenderDimensions(candidate, viewport); - getViewerViewportRenderAsset( - candidate, - render.width, - render.height, + prepareViewerImages(images, activeIndex, { + preloadOriginals: !viewportBitmapCapable, + }); + if (!viewportBitmapCapable) return; + let disposed = false; + let delayTimer = 0; + let idleHandle = 0; + const activeAsset = useViewportBitmapRenderer + ? getViewerViewportRenderAsset( + image, + viewportRender.width, + viewportRender.height, 0, - offset === 0 ? "high" : "low", - ); - } + "high", + ) + : getViewerOriginalAsset(image, 0, "high"); + const idleScheduler = window as IdleScheduler; + const preloadNeighbors = async () => { + if (disposed || !canPreloadViewerNeighbors()) return; + // Forward navigation is the common path. Prepare one neighbor at a time, using + // its original only when it is already no larger than the target render size. + for (const offset of [1, -1]) { + if (disposed) return; + const candidate = images[activeIndex + offset]; + if (!candidate) continue; + const render = viewportRenderDimensions(candidate, viewport); + const strategy = selectViewerSourceStrategy( + candidate, + render, + viewportBitmapCapable, + ); + const asset = strategy === "viewport-upgrade" + ? getViewerViewportRenderAsset( + candidate, + render.width, + render.height, + 0, + "low", + ) + : getViewerOriginalAsset(candidate, 0, "low"); + if (asset.status === "loading") await asset.promise; + } + }; + const scheduleNeighbors = () => { + if (disposed) return; + delayTimer = window.setTimeout(() => { + if (idleScheduler.requestIdleCallback) { + idleHandle = idleScheduler.requestIdleCallback( + () => void preloadNeighbors(), + { timeout: 600 }, + ); + } else { + void preloadNeighbors(); + } + }, MOBILE_NEIGHBOR_PRELOAD_DELAY_MS); + }; + if (activeAsset.status === "loading") void activeAsset.promise.then(scheduleNeighbors); + else scheduleNeighbors(); + return () => { + disposed = true; + window.clearTimeout(delayTimer); + if (idleHandle) idleScheduler.cancelIdleCallback?.(idleHandle); + }; }, [ activeIndex, + image, images, useViewportBitmapRenderer, + viewportBitmapCapable, viewport.height, viewport.width, ]); @@ -794,45 +1037,14 @@ export function ImageViewer({ useEffect(() => { if ( !useViewportBitmapRenderer - || !currentLoadState.loaded || currentFullResolution.requested - || dragging + || transform.scale <= MIN_SCALE ) return; - let disposed = false; - let delayTimer = 0; - let idleHandle = 0; - let frame = 0; - const requestUpgrade = () => { - if (disposed) return; - setFullResolutionState((current) => current.id === image.id - ? { ...current, requested: true, failed: false } - : { id: image.id, requested: true, loaded: false, failed: false }); - }; - const idleScheduler = window as unknown as { - requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number; - cancelIdleCallback?: (handle: number) => void; - }; - if (transform.scale > MIN_SCALE) { - frame = window.requestAnimationFrame(requestUpgrade); - } else { - delayTimer = window.setTimeout(() => { - if (idleScheduler.requestIdleCallback) { - idleHandle = idleScheduler.requestIdleCallback(requestUpgrade, { timeout: 360 }); - } else { - frame = window.requestAnimationFrame(requestUpgrade); - } - }, MOBILE_ORIGINAL_UPGRADE_DELAY_MS); - } - return () => { - disposed = true; - window.clearTimeout(delayTimer); - if (frame) window.cancelAnimationFrame(frame); - if (idleHandle) idleScheduler.cancelIdleCallback?.(idleHandle); - }; + setFullResolutionState((current) => current.id === image.id + ? { ...current, requested: true, failed: false } + : { id: image.id, requested: true, loaded: false, failed: false }); }, [ currentFullResolution.requested, - currentLoadState.loaded, - dragging, image.id, transform.scale, useViewportBitmapRenderer, @@ -852,10 +1064,7 @@ export function ImageViewer({ prepared = prepareSwipeSnapshot(); if (prepared) preparedSwipeSnapshotRef.current = prepared; }; - const idleScheduler = window as unknown as { - requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number; - cancelIdleCallback?: (handle: number) => void; - }; + const idleScheduler = window as IdleScheduler; if (idleScheduler.requestIdleCallback) { idleHandle = idleScheduler.requestIdleCallback(prepare, { timeout: 180 }); } else { @@ -879,41 +1088,74 @@ export function ImageViewer({ useEffect(() => () => { window.clearTimeout(retryTimerRef.current); + window.clearTimeout(sourceUpgradeTimerRef.current); window.clearTimeout(swipeCleanupTimerRef.current); + cancelTransformFrame(); swipeOutgoingRef.current?.remove(); preparedSwipeSnapshotRef.current = null; }, []); const handleOriginalError = () => { - if (currentLoadState.attempt === 0) { + const { attempt } = currentLoadState; + if (dialogRef.current?.dataset.imageId !== image.id) return; + if (attempt === 0) { window.clearTimeout(retryTimerRef.current); retryTimerRef.current = window.setTimeout(() => { - setLoadState({ id: image.id, attempt: 1, loaded: false, failed: false }); + if (dialogRef.current?.dataset.imageId !== image.id) return; + setLoadState((current) => current.id === image.id && current.attempt !== attempt + ? current + : pendingViewerSourceState(image.id, attempt + 1)); }, 900); return; } - setLoadState({ - id: image.id, - attempt: currentLoadState.attempt, - loaded: false, - failed: true, - }); + setLoadState((current) => current.id === image.id && current.attempt !== attempt + ? current + : pendingViewerSourceState(image.id, attempt, true)); }; const retryOriginal = () => { window.clearTimeout(retryTimerRef.current); - setLoadState({ - id: image.id, - attempt: currentLoadState.attempt + 1, - loaded: false, - failed: false, - }); + setLoadState(pendingViewerSourceState( + image.id, + currentLoadState.attempt + 1, + )); }; useLayoutEffect(() => { + if ( + !useViewportBitmapRenderer + || viewerIsBusy() + ) return; + const asset = getReadyViewerViewportRenderAsset( + image, + viewportRender.width, + viewportRender.height, + currentLoadState.attempt, + ); + const canvas = canvasRef.current; + if (!asset?.bitmap || !canvas) return; + const sourceKey = `${fullSource}@${asset.width}x${asset.height}`; + try { + if (canvas.dataset.renderedSource !== sourceKey) { + drawViewerCanvas(canvas, asset.bitmap, asset.width, asset.height, sourceKey); + } + markOriginalReady(image.id, currentLoadState.attempt, "direct"); + } catch (error) { + console.warn("Pixhelf could not reuse the prepared viewer image", error); + } + }, [ + fullSource, + image.id, + useViewportBitmapRenderer, + viewportRender.height, + viewportRender.width, + ]); + + useEffect(() => { if (!useCanvasRenderer) return; let disposed = false; let frame = 0; + let settleTimer = 0; const attempt = currentLoadState.attempt; const viewportAsset = useViewportBitmapRenderer ? getViewerViewportRenderAsset( @@ -945,22 +1187,14 @@ export function ImageViewer({ ?? Math.max(1, Math.round(image.height * renderScale)); const source = viewportAsset?.bitmap ?? originalAsset?.element; const canvas = canvasRef.current; - const context = canvas?.getContext("2d", { alpha: true }); - if (!canvas || !context || !source) 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(source, 0, 0, renderWidth, renderHeight); + if (!canvas || !source) throw new Error("canvas renderer is unavailable"); + const sourceKey = `${fullSource}@${renderWidth}x${renderHeight}`; + if (canvas.dataset.renderedSource !== sourceKey) { + drawViewerCanvas(canvas, source, renderWidth, renderHeight, sourceKey); + } window.clearTimeout(retryTimerRef.current); - setLoadState({ - id: image.id, - attempt, - loaded: true, - failed: false, - }); + markOriginalReady(image.id, attempt); } catch (error) { if (disposed) return; console.warn("Pixhelf could not render the original image", error); @@ -969,20 +1203,33 @@ export function ImageViewer({ }; const scheduleRender = () => { - if (disposed || frame) return; + if (disposed || frame || settleTimer) return; + const swipeInProgress = useViewportBitmapRenderer + && dialogRef.current?.hasAttribute("data-swipe-direction"); + if (viewerIsBusy(useViewportBitmapRenderer)) { + settleTimer = window.setTimeout(() => { + settleTimer = 0; + scheduleRender(); + }, swipeInProgress ? MOBILE_RENDER_SETTLE_MS : 48); + return; + } frame = window.requestAnimationFrame(() => { frame = 0; + if (viewerIsBusy(useViewportBitmapRenderer)) { + scheduleRender(); + return; + } renderOriginal(); }); }; - if (viewportAsset?.status === "ready") renderOriginal(); - else scheduleRender(); + scheduleRender(); const pending = viewportAsset ?? originalAsset; if (pending?.status === "loading") void pending.promise.then(scheduleRender); return () => { disposed = true; if (frame) window.cancelAnimationFrame(frame); + window.clearTimeout(settleTimer); }; }, [ fullSource, @@ -999,6 +1246,19 @@ export function ImageViewer({ const beginContact = (id: number, point: PointerPoint, pointerType: string) => { if (pointerType !== "mouse") lastTouchAtRef.current = performance.now(); if (pointerType !== "mouse" && pointersRef.current.size === 0) clearSwipeMotion(); + if (pointersRef.current.size === 0) { + const surface = surfaceRef.current; + const media = mediaRef.current; + if (surface && media) { + gestureGeometryRef.current = { + surfaceWidth: surface.clientWidth, + surfaceHeight: surface.clientHeight, + mediaWidth: media.offsetWidth, + mediaHeight: media.offsetHeight, + surfaceBounds: surface.getBoundingClientRect(), + }; + } + } pointersRef.current.set(id, point); setDragging(true); @@ -1042,9 +1302,9 @@ export function ImageViewer({ MIN_SCALE, MAX_SCALE, ); - const surface = surfaceRef.current; - if (!surface) return; - const bounds = surface.getBoundingClientRect(); + const bounds = gestureGeometryRef.current?.surfaceBounds + ?? surfaceRef.current?.getBoundingClientRect(); + if (!bounds) return; const center = { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2, @@ -1053,7 +1313,7 @@ export function ImageViewer({ 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({ + stageTransform(constrainTransform({ scale, x: midpoint.x - center.x - imagePoint.x * scale, y: midpoint.y - center.y - imagePoint.y * scale, @@ -1081,7 +1341,7 @@ export function ImageViewer({ if (gestureModeRef.current === "pan") { forceViewerScroll(0); - commitTransform(constrainTransform({ + stageTransform(constrainTransform({ ...start.transform, x: start.transform.x + deltaX, y: start.transform.y + deltaY, @@ -1092,8 +1352,11 @@ export function ImageViewer({ const surface = surfaceRef.current; if (gestureModeRef.current === "navigate") { const navigationAvailable = deltaX < 0 ? canNext : deltaX > 0 && canPrevious; - const maximumTravel = Math.max(1, (surface?.clientWidth ?? 360) * 0.94); - commitTransform({ + const maximumTravel = Math.max( + 1, + (gestureGeometryRef.current?.surfaceWidth ?? surface?.clientWidth ?? 360) * 0.94, + ); + stageTransform({ scale: 1, x: navigationAvailable ? clamp(deltaX * 0.92, -maximumTravel, maximumTravel) @@ -1107,14 +1370,20 @@ export function ImageViewer({ viewer.scrollTop = clamp(start.scrollTop - deltaY, 0, detailsTop); scrollTopRef.current = viewer.scrollTop; syncScrollPage(viewer.scrollTop); - if (transformRef.current.x || transformRef.current.y) commitTransform(DEFAULT_TRANSFORM); + if (transformRef.current.x || transformRef.current.y) stageTransform(DEFAULT_TRANSFORM); return; } } else if (gestureModeRef.current === "dismiss") { - commitTransform({ + stageTransform({ scale: 1, x: 0, - y: rubberBand(Math.max(0, deltaY), Math.max(80, (surface?.clientHeight ?? 640) * 0.2)), + y: rubberBand( + Math.max(0, deltaY), + Math.max( + 80, + (gestureGeometryRef.current?.surfaceHeight ?? surface?.clientHeight ?? 640) * 0.2, + ), + ), }); } }; @@ -1142,6 +1411,7 @@ export function ImageViewer({ point: PointerPoint, pointerType: string, ) => { + flushStagedTransform(); const pointerCount = pointersRef.current.size; pointersRef.current.delete(id); @@ -1176,12 +1446,17 @@ export function ImageViewer({ if (currentScroll > VIEWER_SCROLL_EPSILON && detailsTop > 0) { if (currentScroll >= detailsTop * 0.18) scrollToDetails(); else scrollToImage(); + gestureGeometryRef.current = null; return; } commitTransform(constrainTransform(transformRef.current)); + gestureGeometryRef.current = null; + return; + } + if (!start) { + gestureGeometryRef.current = null; return; } - if (!start) return; const deltaX = point.x - start.point.x; const deltaY = point.y - start.point.y; @@ -1191,30 +1466,52 @@ export function ImageViewer({ if (start.transform.scale > MIN_SCALE) { if (isTap && start.pointerType !== "mouse") { handleTouchTap(point); + gestureGeometryRef.current = null; return; } commitTransform(constrainTransform(transformRef.current)); + gestureGeometryRef.current = null; 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 surfaceWidth = gestureGeometryRef.current?.surfaceWidth + ?? surface?.clientWidth + ?? 360; + const isTouchSwipe = start.pointerType === "touch"; + const horizontalDistance = Math.abs(deltaX); + const horizontalThreshold = isTouchSwipe + ? clamp( + surfaceWidth * TOUCH_SWIPE_DISTANCE_RATIO, + TOUCH_SWIPE_MIN_DISTANCE_PX, + TOUCH_SWIPE_MAX_DISTANCE_PX, + ) + : Math.max(54, surfaceWidth * 0.11); + const horizontalFlick = horizontalDistance > ( + isTouchSwipe ? TOUCH_SWIPE_FLICK_MIN_DISTANCE_PX : 30 + ) && horizontalDistance / elapsed > ( + isTouchSwipe ? TOUCH_SWIPE_FLICK_VELOCITY : 0.48 + ); + const verticalThreshold = Math.max( + 84, + (gestureGeometryRef.current?.surfaceHeight ?? surface?.clientHeight ?? 640) * 0.12, + ); const verticalFlick = deltaY > 38 && deltaY / elapsed > 0.52; const detailsFlick = deltaY < -38 && -deltaY / elapsed > 0.52; if ( gestureMode === "navigate" && - Math.abs(deltaX) > Math.abs(deltaY) && - (Math.abs(deltaX) >= horizontalThreshold || horizontalFlick) + horizontalDistance > Math.abs(deltaY) && + (horizontalDistance >= horizontalThreshold || horizontalFlick) ) { if (deltaX < 0 && canNext) { navigateWithSwipeMotion(1); + gestureGeometryRef.current = null; return; } if (deltaX > 0 && canPrevious) { navigateWithSwipeMotion(-1); + gestureGeometryRef.current = null; return; } } @@ -1227,6 +1524,7 @@ export function ImageViewer({ } else { scrollToImage(); } + gestureGeometryRef.current = null; return; } if ( @@ -1235,6 +1533,7 @@ export function ImageViewer({ (deltaY >= verticalThreshold || verticalFlick) ) { onClose(); + gestureGeometryRef.current = null; return; } @@ -1242,9 +1541,11 @@ export function ImageViewer({ if (isTap && start.pointerType !== "mouse") { handleTouchTap(point); } + gestureGeometryRef.current = null; }; const cancelContacts = () => { + flushStagedTransform(); pointersRef.current.clear(); gestureStartRef.current = null; pinchStartRef.current = null; @@ -1260,9 +1561,11 @@ export function ImageViewer({ } else { scrollToImage(); } + gestureGeometryRef.current = null; return; } commitTransform(constrainTransform(transformRef.current)); + gestureGeometryRef.current = null; }; const beginPointer = (event: JSX.TargetedPointerEvent) => { @@ -1405,10 +1708,16 @@ export function ImageViewer({ }; const displayPosition = Math.min(total, activeIndex + 1); + const liveTransform = transformRef.current; 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) + liveTransform.scale === 1 + ? clamp( + Math.max(0, liveTransform.y) + / Math.max(1, gestureGeometryRef.current?.surfaceHeight ?? surfaceRef.current?.clientHeight ?? 640), + 0, + 0.45, + ) : 0, ), } as CSSProperties; @@ -1423,11 +1732,24 @@ export function ImageViewer({ ? "横向" : "竖向"; const megapixels = image.width * image.height / 1_000_000; + const nativeOriginalActive = currentFullResolution.loaded + && liveTransform.scale > MIN_SCALE; + const displaySource = resolveViewerDisplaySource({ + sourceLoaded: currentLoadState.loaded, + thumbnailLoaded, + viewportBitmapRenderer: useViewportBitmapRenderer, + nativeOriginalActive, + }); + const renderer = sourceStrategy === "viewport-upgrade" + ? "viewport-bitmap" + : sourceStrategy === "bounded-canvas" + ? "safe-canvas" + : "native"; 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})`, + transform: `translate3d(${liveTransform.x}px, ${liveTransform.y}px, 0) scale(${liveTransform.scale})`, } as CSSProperties; const viewer = (
MIN_SCALE - ? "original" - : "viewport-bitmap" - : thumbnailLoaded - ? "thumbnail" - : "placeholder"} + data-display-source={displaySource} data-native-original-requested={currentFullResolution.requested} data-native-original-loaded={currentFullResolution.loaded} - data-native-original-active={ - currentFullResolution.loaded && transform.scale > MIN_SCALE - } - data-zoomed={transform.scale > MIN_SCALE} + data-native-original-active={nativeOriginalActive} + data-zoomed={liveTransform.scale > MIN_SCALE} data-dragging={dragging} data-page={scrollPage} - data-renderer={useViewportBitmapRenderer - ? "viewport-bitmap" - : useSafeCanvasRenderer - ? "safe-canvas" - : "native"} + data-renderer={renderer} data-control-system="unified" data-scroll-mode="continuous" data-mouse-side-navigation="true" data-mobile-swipe-motion="interruptible" + data-mobile-gesture-renderer="raf-dom" + data-mobile-original-policy={useViewportBitmapRenderer ? "zoom-only" : "direct"} tabIndex={-1} onScroll={handleViewerScroll} style={viewerStyle} @@ -1572,14 +1885,15 @@ export function ImageViewer({ decoding="async" fetchPriority="high" draggable={false} - onLoad={() => { + onLoad={(event) => { + const original = event.currentTarget; window.clearTimeout(retryTimerRef.current); - displayedOriginalsRef.current.add(fullSource); - setLoadState({ - id: image.id, - attempt: currentLoadState.attempt, - loaded: true, - failed: false, + const decoded = typeof original.decode === "function" + ? original.decode().catch(() => undefined) + : Promise.resolve(); + void decoded.then(() => { + displayedOriginalsRef.current.add(fullSource); + markOriginalReady(image.id, currentLoadState.attempt); }); }} onError={handleOriginalError} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 2b74070..76479d2 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1144,9 +1144,9 @@ select:focus-visible, } .viewer-thumbnail { - filter: saturate(.88) brightness(.84) blur(.35px); - transform: scale(1.002); - transition: filter 160ms ease, opacity 140ms ease; + filter: none; + transform: none; + transition: none; } .viewer-original { @@ -1157,7 +1157,7 @@ select:focus-visible, max-width: none !important; max-height: none !important; opacity: 0; - transition: opacity 140ms ease; + transition: opacity 160ms ease-out; } .viewer-original-canvas { object-fit: fill; } @@ -1175,10 +1175,19 @@ select:focus-visible, .image-viewer[data-full-loaded="true"] .viewer-original { opacity: 1; } -.image-viewer[data-full-loaded="true"] .viewer-thumbnail { opacity: 0; } +.image-viewer[data-source-presentation="direct"] + .viewer-original:not(.viewer-native-original) { + transition: none; +} -.image-viewer[data-full-failed="true"] .viewer-thumbnail { - filter: saturate(.92) brightness(.9); +.image-viewer[data-full-loaded="true"] .viewer-thumbnail { + opacity: 0; + transition: opacity 0s linear 160ms; +} + +.image-viewer[data-source-presentation="direct"][data-full-loaded="true"] + .viewer-thumbnail { + transition: none; } .viewer-nav { diff --git a/frontend/src/viewerAssets.ts b/frontend/src/viewerAssets.ts index c90d2d8..27a9528 100644 --- a/frontend/src/viewerAssets.ts +++ b/frontend/src/viewerAssets.ts @@ -1,11 +1,13 @@ import type { GalleryImage } from "./types"; const THUMBNAIL_CACHE_LIMIT = 32; -const ORIGINAL_CACHE_LIMIT = 8; +const ORIGINAL_CACHE_LIMIT = 4; const VIEWPORT_RENDER_CACHE_LIMIT = 4; const THUMBNAIL_PRELOAD_DISTANCE = 10; const ORIGINAL_PRELOAD_DISTANCE = 2; +type AssetPriority = "high" | "low"; + type NetworkInformation = { effectiveType?: string; saveData?: boolean; @@ -26,12 +28,25 @@ export type ViewerRenderAsset = { status: ViewerAssetStatus; width: number; height: number; + priority: AssetPriority; lastUsed: number; }; +type ViewportRenderJob = { + asset: ViewerRenderAsset; + image: GalleryImage; + attempt: number; + key: string; + controller: AbortController; + cancelled: boolean; + resolve: () => void; +}; + const thumbnailAssets = new Map(); const originalAssets = new Map(); const viewportRenderAssets = new Map(); +const viewportRenderQueue: ViewportRenderJob[] = []; +let activeViewportRenderJob: ViewportRenderJob | null = null; let assetClock = 0; export function viewerThumbnailUrl(image: GalleryImage): string { @@ -50,6 +65,17 @@ function canSpeculativelyPreloadOriginals(): boolean { return connection?.effectiveType !== "slow-2g" && connection?.effectiveType !== "2g"; } +export function supportsViewerViewportBitmaps(): boolean { + if (typeof createImageBitmap !== "function") return false; + if (window.innerWidth <= 720) return true; + return navigator.maxTouchPoints > 0 + && window.matchMedia("(pointer: coarse)").matches; +} + +export function canPreloadViewerNeighbors(): boolean { + return canSpeculativelyPreloadOriginals(); +} + function pruneCache( cache: Map, limit: number, @@ -69,7 +95,7 @@ function loadImageAsset( cache: Map, url: string, limit: number, - priority: "high" | "low", + priority: AssetPriority, ): ViewerImageAsset { const cached = cache.get(url); if (cached) { @@ -116,7 +142,7 @@ function loadImageAsset( export function preloadViewerThumbnail( image: GalleryImage, - priority: "high" | "low" = "low", + priority: AssetPriority = "low", ): ViewerImageAsset { return loadImageAsset( thumbnailAssets, @@ -133,7 +159,7 @@ export function getViewerThumbnailStatus(image: GalleryImage): ViewerAssetStatus export function getViewerOriginalAsset( image: GalleryImage, attempt = 0, - priority: "high" | "low" = "high", + priority: AssetPriority = "high", ): ViewerImageAsset { return loadImageAsset( originalAssets, @@ -150,13 +176,19 @@ export function getViewerOriginalStatus( return originalAssets.get(viewerOriginalUrl(image, attempt))?.status ?? "idle"; } -function viewportRenderKey( +function viewportRenderRequest( image: GalleryImage, width: number, height: number, attempt: number, -): string { - return `${viewerOriginalUrl(image, attempt)}@${width}x${height}`; +): { key: string; width: number; height: number } { + const renderWidth = Math.max(1, Math.round(width)); + const renderHeight = Math.max(1, Math.round(height)); + return { + key: `${viewerOriginalUrl(image, attempt)}@${renderWidth}x${renderHeight}`, + width: renderWidth, + height: renderHeight, + }; } function pruneViewportRenderCache(protectedKey?: string): void { @@ -171,19 +203,96 @@ function pruneViewportRenderCache(protectedKey?: string): void { } } +function cancelViewportRenderJob(job: ViewportRenderJob): void { + job.cancelled = true; + job.controller.abort(); + job.asset.status = "failed"; + if (viewportRenderAssets.get(job.key) === job.asset) { + viewportRenderAssets.delete(job.key); + } +} + +function cancelStaleViewportRenderJobs(activeKey: string): void { + for (let index = viewportRenderQueue.length - 1; index >= 0; index -= 1) { + const job = viewportRenderQueue[index]!; + if (job.key === activeKey) continue; + viewportRenderQueue.splice(index, 1); + cancelViewportRenderJob(job); + job.resolve(); + } + if (activeViewportRenderJob && activeViewportRenderJob.key !== activeKey) { + cancelViewportRenderJob(activeViewportRenderJob); + } +} + +function startNextViewportRenderJob(): void { + if (activeViewportRenderJob || viewportRenderQueue.length === 0) return; + viewportRenderQueue.sort((first, second) => { + if (first.asset.priority !== second.asset.priority) { + return first.asset.priority === "high" ? -1 : 1; + } + return second.asset.lastUsed - first.asset.lastUsed; + }); + const job = viewportRenderQueue.shift()!; + activeViewportRenderJob = job; + void (async () => { + const { asset, attempt, controller, image, key, resolve } = job; + try { + const requestOptions: RequestInit & { priority: "high" | "low" } = { + cache: attempt ? "reload" : "force-cache", + credentials: "same-origin", + priority: asset.priority, + signal: controller.signal, + }; + const response = await fetch(viewerOriginalUrl(image, attempt), requestOptions); + 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 bitmap = await createImageBitmap(blob, { + imageOrientation: "from-image", + resizeWidth: asset.width, + resizeHeight: asset.height, + resizeQuality: "high", + }); + if (job.cancelled) { + bitmap.close(); + return; + } + asset.bitmap = bitmap; + asset.status = "ready"; + } catch { + asset.status = "failed"; + } finally { + if (job.cancelled) { + asset.bitmap?.close(); + asset.bitmap = null; + asset.status = "failed"; + if (viewportRenderAssets.get(key) === asset) viewportRenderAssets.delete(key); + } + asset.lastUsed = ++assetClock; + if (activeViewportRenderJob === job) activeViewportRenderJob = null; + resolve(); + pruneViewportRenderCache(key); + startNextViewportRenderJob(); + } + })(); +} + export function getViewerViewportRenderAsset( image: GalleryImage, width: number, height: number, attempt = 0, - priority: "high" | "low" = "high", + priority: AssetPriority = "high", ): ViewerRenderAsset { - const renderWidth = Math.max(1, Math.round(width)); - const renderHeight = Math.max(1, Math.round(height)); - const key = viewportRenderKey(image, renderWidth, renderHeight, attempt); + const request = viewportRenderRequest(image, width, height, attempt); + const { key } = request; + if (priority === "high") cancelStaleViewportRenderJobs(key); const cached = viewportRenderAssets.get(key); if (cached) { cached.lastUsed = ++assetClock; + if (priority === "high") cached.priority = "high"; + startNextViewportRenderJob(); return cached; } @@ -194,49 +303,41 @@ export function getViewerViewportRenderAsset( resolveAsset = resolve; }), status: "loading", - width: renderWidth, - height: renderHeight, + width: request.width, + height: request.height, + priority, lastUsed: ++assetClock, }; viewportRenderAssets.set(key, asset); - const original = getViewerOriginalAsset(image, attempt, priority); - void original.promise.then(async () => { - try { - if (original.status !== "ready" || typeof createImageBitmap !== "function") { - asset.status = "failed"; - return; - } - asset.bitmap = await createImageBitmap(original.element, { - resizeWidth: renderWidth, - resizeHeight: renderHeight, - resizeQuality: "high", - }); - asset.status = "ready"; - } catch { - asset.status = "failed"; - } finally { - asset.lastUsed = ++assetClock; - resolveAsset(); - pruneViewportRenderCache(key); - } + // Decode the compressed response straight into the viewport-sized bitmap. Using an + // HTMLImageElement here keeps the *full* decoded original alive as well; a single + // high-resolution phone photo can otherwise retain hundreds of megabytes while the + // user is swiping through neighboring images. + viewportRenderQueue.push({ + asset, + image, + attempt, + key, + controller: new AbortController(), + cancelled: false, + resolve: resolveAsset, }); + startNextViewportRenderJob(); pruneViewportRenderCache(key); return asset; } -export function getViewerViewportRenderStatus( +export function getReadyViewerViewportRenderAsset( image: GalleryImage, width: number, height: number, attempt = 0, -): ViewerAssetStatus | "idle" { - const key = viewportRenderKey( - image, - Math.max(1, Math.round(width)), - Math.max(1, Math.round(height)), - attempt, - ); - return viewportRenderAssets.get(key)?.status ?? "idle"; +): ViewerRenderAsset | null { + const { key } = viewportRenderRequest(image, width, height, attempt); + const asset = viewportRenderAssets.get(key); + if (asset?.status !== "ready" || !asset.bitmap) return null; + asset.lastUsed = ++assetClock; + return asset; } export function getDecodedViewerOriginal(source: string): HTMLImageElement | null { @@ -246,12 +347,21 @@ export function getDecodedViewerOriginal(source: string): HTMLImageElement | nul } export function preloadOriginalImage(image: GalleryImage): void { - if (!canSpeculativelyPreloadOriginals()) return; + if ( + supportsViewerViewportBitmaps() + || !canSpeculativelyPreloadOriginals() + ) return; getViewerOriginalAsset(image, 0, "low"); } -export function prepareViewerImages(images: GalleryImage[], activeIndex: number): void { +export function prepareViewerImages( + images: GalleryImage[], + activeIndex: number, + options: { preloadOriginals?: boolean } = {}, +): void { if (activeIndex < 0 || activeIndex >= images.length) return; + const preloadOriginals = options.preloadOriginals + ?? !supportsViewerViewportBitmaps(); // Thumbnail preparation is deliberately independent from masonry visibility. It is // cheap enough to keep a generous navigation window decoded even on constrained links. @@ -263,8 +373,12 @@ export function prepareViewerImages(images: GalleryImage[], activeIndex: number) } } - // The active original is always requested. Neighboring originals are speculative and - // respect Save-Data/slow-network signals; their thumbnails remain available either way. + // Viewport-bitmap clients fetch compressed originals directly into bounded bitmaps. + // Avoid also decoding the same originals into hidden HTMLImageElements. + if (!preloadOriginals) return; + + // The active desktop original is always requested. Neighboring originals are + // speculative and respect Save-Data/slow-network signals. getViewerOriginalAsset(images[activeIndex]!, 0, "high"); if (!canSpeculativelyPreloadOriginals()) return; for (let distance = 1; distance <= ORIGINAL_PRELOAD_DISTANCE; distance += 1) {