diff --git a/Cargo.lock b/Cargo.lock index 61b1075..9bdd2c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -618,7 +618,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pixhelf" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 5bb52dd..6661d9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pixhelf" -version = "0.1.4" +version = "0.1.5" edition = "2024" build = "build.rs" diff --git a/README.md b/README.md index 3f178d1..8f3b0ef 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ HTTP 服务会立即开放,并优先生成首批 60 张缩略图。每张图 首屏图库数据与压缩后的关键样式会随 HTML 一并下发,首张图片会提前加载。JS 使用内容版本化的长期缓存,API 使用 ETag 协商缓存,并按浏览器能力启用 Brotli 或 gzip 压缩。 -缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的单一版本化目录,原图不会被修改。点击图片后,查看器会先显示这份缩略图,再加载原图,并提前预取相邻原图;超大原图会在浏览器内解码到 GPU 安全画布,不会生成第二套预览缓存。查看器支持键盘切换、滚轮缩放,以及移动端滑动、双击和双指缩放。缓存目录必须位于图库目录之外。 +缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的单一版本化目录,原图不会被修改。查看器拥有独立于瀑布流可见性的资源队列:提前解码切图窗口内的缩略图并预取相邻原图,切换目标时立即显示对应缩略图,原图下载且解码完成后再无闪烁替换;桌面端会直接复用已就绪的常规原图,移动端切图首帧先使用与视口及 DPR 匹配的高质量位图,当前图片稳定后在后台自动加载真实原图,放大时立即切换,复位后回到更适合屏幕采样的视口位图,连续切图会取消过期升级任务。只有超过安全边界的极端大图才绘制到 GPU 安全画布。查看器支持键盘方向键、鼠标前进/后退侧键和滚轮缩放,以及移动端滑动、双击和双指缩放;在适应屏幕状态下继续向下滚动或上滑,可从图片首屏连续进入详情第二屏。关闭查看器时会在瀑布流布局稳定后把当前图片动画送回对应卡片,即使期间发生续载或窗口尺寸变化也会重新锁定落点。窗口宽度引发瀑布流重排时,会按图片 ID 保持当前焦点或视口中心图片的相对位置。缓存目录必须位于图库目录之外。 ## 构建 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 84601c0..287d5a7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "pixhelf-frontend", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pixhelf-frontend", - "version": "0.1.4", + "version": "0.1.5", "dependencies": { "preact": "^10.27.1" }, diff --git a/frontend/package.json b/frontend/package.json index 5f24a46..68a452f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "pixhelf-frontend", "private": true, - "version": "0.1.4", + "version": "0.1.5", "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 7c23c3c..45c98ba 100644 --- a/frontend/scripts/visual-check.mjs +++ b/frontend/scripts/visual-check.mjs @@ -4,6 +4,8 @@ const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ?? "/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome"; const baseUrl = process.env.PIXHELF_URL ?? "http://127.0.0.1:3002"; +const requestedTarget = process.env.PIXHELF_VISUAL_TARGET; +const viewerReturnOnly = process.env.PIXHELF_VIEWER_RETURN_ONLY === "1"; const browser = await chromium.launch({ executablePath, @@ -14,10 +16,11 @@ const browser = await chromium.launch({ const results = []; try { - for (const target of [ + const targets = [ { name: "desktop", width: 1440, height: 1000 }, { name: "mobile", width: 390, height: 844 }, - ]) { + ].filter((target) => !requestedTarget || target.name === requestedTarget); + for (const target of targets) { const page = await browser.newPage({ viewport: { width: target.width, height: target.height }, deviceScaleFactor: target.name === "mobile" ? 3 : 1, @@ -31,14 +34,15 @@ try { await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: 60_000 }); await page.waitForSelector("[data-image-id]", { timeout: 60_000 }); - await page.waitForFunction(() => { + const minimumVisibleImages = target.name === "mobile" ? 4 : 8; + await page.waitForFunction((minimumVisible) => { const visible = [...document.querySelectorAll("[data-image-id] img")].filter((image) => { const rect = image.getBoundingClientRect(); return rect.bottom > 0 && rect.top < innerHeight; }); - return visible.length >= 8 && + return visible.length >= minimumVisible && visible.every((image) => image.complete && image.naturalWidth > 0); - }, undefined, { timeout: 60_000 }); + }, minimumVisibleImages, { timeout: 60_000 }); await page.waitForTimeout(250); await page.screenshot({ path: `/tmp/pixhelf-${target.name}.png` }); @@ -143,6 +147,32 @@ try { .length; }); + const resizeAnchor = await page.evaluate(() => { + const cards = [...document.querySelectorAll(".masonry .image-card")]; + const card = cards[Math.min(24, cards.length - 1)]; + if (!(card instanceof HTMLElement)) return null; + card.scrollIntoView({ block: "center", behavior: "instant" }); + card.focus({ preventScroll: true }); + const rect = card.getBoundingClientRect(); + const viewportTop = visualViewport?.offsetTop ?? 0; + const viewportHeight = visualViewport?.height ?? innerHeight; + const viewportBottom = viewportTop + viewportHeight; + const topbarBottom = document.querySelector(".topbar")?.getBoundingClientRect().bottom + ?? viewportTop; + const safeTop = Math.min(viewportBottom, Math.max(viewportTop, topbarBottom + 8)); + const safeBottom = Math.max(safeTop, viewportBottom - 8); + const visibleTop = Math.max(safeTop, rect.top); + const visibleBottom = Math.min(safeBottom, rect.bottom); + const anchorY = (visibleTop + visibleBottom) / 2; + return { + imageId: card.dataset.imageId ?? "", + cardRatio: (anchorY - rect.top) / Math.max(1, rect.height), + viewportRatio: (anchorY - viewportTop) / Math.max(1, viewportHeight), + }; + }); + if (!resizeAnchor) throw new Error("masonry resize anchor could not be captured"); + const resizeAnchorDeltas = []; + for (const size of [ { width: 680, columns: 3 }, { width: 520, columns: 2 }, @@ -153,7 +183,78 @@ try { await page.waitForFunction((columns) => document.querySelector(".masonry")?.getAttribute("data-columns") === String(columns) , size.columns); - await page.waitForTimeout(100); + try { + await page.waitForFunction((anchor) => { + const card = document.querySelector( + `.masonry .image-card[data-image-id="${CSS.escape(anchor.imageId)}"]`, + ); + if (!(card instanceof HTMLElement)) return false; + const rect = card.getBoundingClientRect(); + const viewportTop = visualViewport?.offsetTop ?? 0; + const viewportHeight = visualViewport?.height ?? innerHeight; + const viewportBottom = viewportTop + viewportHeight; + const topbarBottom = document.querySelector(".topbar")?.getBoundingClientRect().bottom + ?? viewportTop; + const safeTop = Math.min(viewportBottom, Math.max(viewportTop, topbarBottom + 8)); + const safeBottom = Math.max(safeTop, viewportBottom - 8); + const expectedY = Math.min( + safeBottom, + Math.max(safeTop, viewportTop + anchor.viewportRatio * viewportHeight), + ); + const actualY = rect.top + rect.height * anchor.cardRatio; + return Math.abs(actualY - expectedY) <= 2; + }, resizeAnchor, { timeout: 4000 }); + } catch (error) { + const actual = await page.evaluate((anchor) => { + const card = document.querySelector( + `.masonry .image-card[data-image-id="${CSS.escape(anchor.imageId)}"]`, + ); + if (!(card instanceof HTMLElement)) return { missing: true }; + const rect = card.getBoundingClientRect(); + const viewportTop = visualViewport?.offsetTop ?? 0; + const viewportHeight = visualViewport?.height ?? innerHeight; + const viewportBottom = viewportTop + viewportHeight; + const topbarBottom = document.querySelector(".topbar")?.getBoundingClientRect().bottom + ?? viewportTop; + const safeTop = Math.min(viewportBottom, Math.max(viewportTop, topbarBottom + 8)); + const safeBottom = Math.max(safeTop, viewportBottom - 8); + const expectedY = Math.min( + safeBottom, + Math.max(safeTop, viewportTop + anchor.viewportRatio * viewportHeight), + ); + const actualY = rect.top + rect.height * anchor.cardRatio; + return { + actualY, + columns: document.querySelector(".masonry")?.getAttribute("data-columns"), + delta: Math.abs(actualY - expectedY), + expectedY, + scrollY, + }; + }, resizeAnchor); + throw new Error( + `masonry resize anchor drifted at ${size.width}px: ${JSON.stringify(actual)}`, + { cause: error }, + ); + } + resizeAnchorDeltas.push(await page.evaluate((anchor) => { + const card = document.querySelector( + `.masonry .image-card[data-image-id="${CSS.escape(anchor.imageId)}"]`, + ); + if (!(card instanceof HTMLElement)) return Infinity; + const rect = card.getBoundingClientRect(); + const viewportTop = visualViewport?.offsetTop ?? 0; + const viewportHeight = visualViewport?.height ?? innerHeight; + const viewportBottom = viewportTop + viewportHeight; + const topbarBottom = document.querySelector(".topbar")?.getBoundingClientRect().bottom + ?? viewportTop; + const safeTop = Math.min(viewportBottom, Math.max(viewportTop, topbarBottom + 8)); + const safeBottom = Math.max(safeTop, viewportBottom - 8); + const expectedY = Math.min( + safeBottom, + Math.max(safeTop, viewportTop + anchor.viewportRatio * viewportHeight), + ); + return Math.abs(rect.top + rect.height * anchor.cardRatio - expectedY); + }, resizeAnchor)); } await page.waitForFunction(() => { @@ -199,6 +300,13 @@ try { ), }; }); + Object.assign(resizeStability, { + anchorId: resizeAnchor.imageId, + anchorDeltas: resizeAnchorDeltas, + anchorFocused: await page.evaluate((imageId) => + document.activeElement?.getAttribute("data-image-id") === imageId + , resizeAnchor.imageId), + }); } const firstViewerTitle = await page.locator("[data-image-id]").first().getAttribute("title"); @@ -206,6 +314,38 @@ try { await page.waitForSelector(".image-viewer"); const viewerChrome = await page.evaluate(() => ({ fullscreenButtons: document.querySelectorAll(".viewer-fullscreen").length, + detailsPages: document.querySelectorAll(".viewer-details-page").length, + scrollCues: document.querySelectorAll(".viewer-scroll-cue").length, + zoomControls: document.querySelectorAll( + ".viewer-zoom-controls, .viewer-zoom-value", + ).length, + scrollCueArrowOnly: (() => { + const cue = document.querySelector(".viewer-scroll-cue"); + return cue?.textContent?.trim() === "" + && cue.children.length === 1 + && cue.firstElementChild?.tagName === "svg" + && cue.querySelectorAll("path").length === 2; + })(), + scrollCuePresentation: (() => { + const cue = document.querySelector(".viewer-scroll-cue"); + const media = document.querySelector(".viewer-media"); + if (!cue || !media) return null; + const style = getComputedStyle(cue); + const cueBounds = cue.getBoundingClientRect(); + const mediaBounds = media.getBoundingClientRect(); + return { + borderless: Number.parseFloat(style.borderTopWidth) === 0, + transparent: style.backgroundColor === "rgba(0, 0, 0, 0)", + width: cueBounds.width, + clearsMedia: cueBounds.top >= mediaBounds.bottom - 1, + }; + })(), + headingModules: document.querySelectorAll(".viewer-heading").length, + unifiedControls: document.querySelector(".image-viewer") + ?.getAttribute("data-control-system") === "unified", + drawerArtifacts: document.querySelectorAll( + ".viewer-details-sheet, .viewer-details-scrim", + ).length, gestureHelp: document.querySelectorAll(".viewer-gesture-help").length, loadingStatus: document.querySelectorAll(".viewer-load-status").length, loadingCopyVisible: document.body.textContent?.includes("正在加载原图") ?? false, @@ -213,6 +353,12 @@ 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) @@ -225,17 +371,37 @@ try { const viewer = await page.evaluate(() => { const overlay = document.querySelector(".image-viewer"); const original = document.querySelector(".viewer-original"); + const nativeOriginal = document.querySelector(".viewer-native-original"); const media = document.querySelector(".viewer-media")?.getBoundingClientRect(); + const stage = document.querySelector(".viewer-stage")?.getBoundingClientRect(); + const details = document.querySelector(".viewer-details-page")?.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 ?? "", + title: overlay?.getAttribute("data-image-name") ?? "", fullLoaded: overlay?.getAttribute("data-full-loaded"), + displaySource: overlay?.getAttribute("data-display-source") ?? "", + nativeOriginalLoaded: overlay?.getAttribute("data-native-original-loaded") ?? "false", + nativeOriginalActive: overlay?.getAttribute("data-native-original-active") ?? "false", + nativeOriginalCount: document.querySelectorAll(".viewer-native-original").length, + nativeOriginalPath: nativeOriginal instanceof HTMLImageElement + ? new URL(nativeOriginal.currentSrc || nativeOriginal.src, location.href).pathname + : "", + nativeOriginalPixels: nativeOriginal instanceof HTMLImageElement + ? [nativeOriginal.naturalWidth, nativeOriginal.naturalHeight] + : [0, 0], + nativeOriginalVisible: nativeOriginal + ? Number.parseFloat(getComputedStyle(nativeOriginal).opacity) + : 0, + mediaWillChange: document.querySelector(".viewer-media") + ? getComputedStyle(document.querySelector(".viewer-media")).willChange + : "", originalPath: originalSource ? new URL(originalSource, location.href).pathname : "", renderer: overlay?.getAttribute("data-renderer"), + scrollMode: overlay?.getAttribute("data-scroll-mode"), renderedPixels: original instanceof HTMLCanvasElement ? [original.width, original.height] : [original?.clientWidth ?? 0, original?.clientHeight ?? 0], @@ -249,10 +415,205 @@ try { ? media.left >= -1 && media.top >= -1 && media.right <= innerWidth + 1 && media.bottom <= innerHeight + 1 : false, - position: document.querySelector(".viewer-heading span")?.textContent ?? "", + continuousLayout: overlay && stage && details + ? Math.abs(stage.top) < 1 && Math.abs(stage.height - innerHeight) < 1 && + Math.abs(details.top - stage.bottom) < 1 && + details.height >= innerHeight - 1 && + overlay.scrollHeight >= innerHeight * 2 - 1 + : false, + position: document.querySelector(".viewer-details-heading span")?.textContent ?? "", }; }); Object.assign(viewer, viewerChrome); + const sideButtonStart = await page.evaluate(() => { + const activeId = document.querySelector(".image-viewer")?.getAttribute("data-image-id") ?? ""; + const ids = [...document.querySelectorAll(".masonry .image-card")] + .map((card) => card.getAttribute("data-image-id") ?? ""); + const index = ids.indexOf(activeId); + return { + activeId, + nextId: ids[index + 1] ?? "", + historyLength: history.length, + href: location.href, + }; + }); + const dispatchViewerSideButton = (button) => page.locator(".image-viewer").evaluate( + (viewerElement, sideButton) => { + const mask = sideButton === 3 ? 8 : 16; + const downPrevented = !viewerElement.dispatchEvent(new PointerEvent("pointerdown", { + bubbles: true, + cancelable: true, + button: sideButton, + buttons: mask, + pointerId: 71, + pointerType: "mouse", + })); + const upPrevented = !viewerElement.dispatchEvent(new PointerEvent("pointerup", { + bubbles: true, + cancelable: true, + button: sideButton, + buttons: 0, + pointerId: 71, + pointerType: "mouse", + })); + const auxiliaryPrevented = !viewerElement.dispatchEvent(new MouseEvent("auxclick", { + bubbles: true, + cancelable: true, + button: sideButton, + })); + return { downPrevented, upPrevented, auxiliaryPrevented }; + }, + button, + ); + const sideForwardEvents = await dispatchViewerSideButton(4); + await page.waitForFunction((imageId) => + document.querySelector(".image-viewer")?.getAttribute("data-image-id") === imageId + , sideButtonStart.nextId); + const sideBackEvents = await dispatchViewerSideButton(3); + 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 }); + if ( + !sideButtonStart.nextId || + sideButtonNavigation.returnedId !== sideButtonStart.activeId || + !sideButtonNavigation.enabled || + !sideButtonNavigation.historyPreserved || + !Object.values(sideButtonNavigation.forward).every(Boolean) || + !Object.values(sideButtonNavigation.back).every(Boolean) + ) { + throw new Error(`viewer side-button navigation failed: ${JSON.stringify(sideButtonNavigation)}`); + } + Object.assign(viewer, { sideButtonNavigation }); + if (target.name === "desktop") { + await page.locator(".viewer-next").click(); + } else { + await page.keyboard.press("ArrowRight"); + } + await page.waitForFunction((imageId) => + document.querySelector(".image-viewer")?.getAttribute("data-image-id") === imageId + , sideButtonStart.nextId); + const switchPerformance = await page.evaluate(async (expectedId) => { + const start = performance.now(); + window.dispatchEvent(new KeyboardEvent("keydown", { + key: "ArrowLeft", + bubbles: true, + cancelable: true, + })); + const dispatchDuration = performance.now() - start; + await new Promise((resolve) => requestAnimationFrame(resolve)); + const firstFrameDuration = performance.now() - start; + const overlay = document.querySelector(".image-viewer"); + const media = document.querySelector(".viewer-media"); + return { + currentId: overlay?.getAttribute("data-image-id") ?? "", + dispatchDuration, + firstFrameDuration, + firstFrameSource: overlay?.getAttribute("data-display-source") ?? "", + focusedOnViewer: document.activeElement === overlay, + visibleControlFocusRings: document.querySelectorAll( + ".image-viewer .viewer-control:focus-visible, " + + ".image-viewer .viewer-nav:focus-visible, " + + ".image-viewer .viewer-scroll-cue:focus-visible", + ).length, + mediaAnimations: media?.getAnimations().length ?? -1, + expectedId, + }; + }, sideButtonStart.activeId); + if ( + switchPerformance.currentId !== switchPerformance.expectedId || + switchPerformance.dispatchDuration > 80 || + switchPerformance.firstFrameDuration > 100 || + switchPerformance.firstFrameSource !== ( + target.name === "mobile" ? "viewport-bitmap" : "original" + ) || + !switchPerformance.focusedOnViewer || + switchPerformance.visibleControlFocusRings !== 0 || + switchPerformance.mediaAnimations !== 0 + ) { + throw new Error(`viewer switch responsiveness failed: ${JSON.stringify(switchPerformance)}`); + } + Object.assign(viewer, { switchPerformance }); + const rapidSwitch = await page.evaluate(async () => { + const overlay = document.querySelector(".image-viewer"); + const ids = [...document.querySelectorAll(".masonry .image-card")] + .map((card) => card.getAttribute("data-image-id") ?? ""); + const startId = overlay?.getAttribute("data-image-id") ?? ""; + const startIndex = ids.indexOf(startId); + const steps = Math.min(6, Math.max(0, ids.length - startIndex - 1)); + const expectedId = ids[startIndex + steps] ?? startId; + const previousMedia = document.querySelector(".viewer-media"); + for (let index = 0; index < steps; index += 1) { + window.dispatchEvent(new KeyboardEvent("keydown", { + key: "ArrowRight", + bubbles: true, + cancelable: true, + })); + } + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const currentOverlay = document.querySelector(".image-viewer"); + const currentId = currentOverlay?.getAttribute("data-image-id") ?? ""; + const thumbnail = document.querySelector(".viewer-thumbnail"); + const original = document.querySelector(".viewer-original"); + const thumbnailPath = thumbnail instanceof HTMLImageElement + ? new URL(thumbnail.currentSrc || thumbnail.src, location.href).pathname + : ""; + const originalSource = original instanceof HTMLImageElement + ? original.currentSrc || original.src + : original?.getAttribute("data-original-url") ?? ""; + const originalPath = originalSource + ? new URL(originalSource, location.href).pathname + : ""; + return { + steps, + startId, + expectedId, + currentId, + mediaReplaced: previousMedia !== document.querySelector(".viewer-media"), + mediaCount: document.querySelectorAll(".viewer-media").length, + thumbnailMatches: thumbnailPath.includes(`/api/images/${encodeURIComponent(currentId)}/thumbnail`), + originalMatches: originalPath.includes(`/api/images/${encodeURIComponent(currentId)}/original`), + immediateSource: currentOverlay?.getAttribute("data-display-source") ?? "", + mediaAnimations: document.querySelector(".viewer-media")?.getAnimations().length ?? -1, + }; + }); + await page.waitForFunction(() => + document.querySelector(".image-viewer")?.getAttribute("data-display-source") !== "placeholder" + , undefined, { timeout: 10_000 }); + rapidSwitch.readySource = await page.locator(".image-viewer").getAttribute("data-display-source"); + 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 ( + rapidSwitch.steps !== 6 || + rapidSwitch.currentId !== rapidSwitch.expectedId || + !rapidSwitch.mediaReplaced || + rapidSwitch.mediaCount !== 1 || + !rapidSwitch.thumbnailMatches || + !rapidSwitch.originalMatches || + rapidSwitch.mediaAnimations !== 0 || + rapidSwitch.readySource === "placeholder" || + rapidSwitch.upgradedSource !== ( + target.name === "mobile" ? "viewport-bitmap" : "original" + ) + ) { + throw new Error(`viewer rapid-switch pipeline failed: ${JSON.stringify(rapidSwitch)}`); + } await page.locator(".viewer-fullscreen").click(); await page.waitForFunction(() => document.fullscreenElement?.classList.contains("image-viewer") @@ -265,20 +626,96 @@ try { 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.keyboard.press("="); await page.waitForFunction(() => document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "true" ); - viewer.zoomed = await page.locator(".viewer-zoom-value").textContent(); + viewer.zoomed = await page.locator(".image-viewer").getAttribute("data-zoomed"); await page.waitForTimeout(260); viewer.zoomScale = await page.locator(".viewer-media").evaluate((media) => new DOMMatrix(getComputedStyle(media).transform).a ); + Object.assign(viewer, await page.locator(".image-viewer").evaluate((overlay) => ({ + zoomKeptImagePage: overlay.scrollTop < 1 && overlay.dataset.page === "image", + zoomLockedPageScroll: getComputedStyle(overlay).overflowY === "hidden", + }))); + if (target.name === "desktop") { + await page.mouse.move(target.width / 2, target.height / 2); + await page.mouse.wheel(0, 1_000); + await page.mouse.wheel(0, 1_000); + await page.waitForFunction(() => + document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "false" + ); + await page.mouse.wheel(0, 1_000); + await page.waitForTimeout(80); + viewer.zoomWheelHandoffLocked = await page.locator(".image-viewer").evaluate((overlay) => + overlay.scrollTop < 1 && overlay.dataset.page === "image" + ); + } + const keyboardStartName = await page.locator(".image-viewer").getAttribute("data-image-name"); 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(); + document.querySelector(".image-viewer")?.getAttribute("data-image-name") !== title + , keyboardStartName); + viewer.keyboardNavigation = await page.locator(".image-viewer").getAttribute("data-image-name"); + const keyboardFocus = await page.evaluate(() => { + const overlay = document.querySelector(".image-viewer"); + return { + parkedOnViewer: document.activeElement === overlay, + visibleControlFocusRings: document.querySelectorAll( + ".image-viewer .viewer-control:focus-visible, " + + ".image-viewer .viewer-nav:focus-visible, " + + ".image-viewer .viewer-scroll-cue:focus-visible", + ).length, + }; + }); + if (!keyboardFocus.parkedOnViewer || keyboardFocus.visibleControlFocusRings !== 0) { + throw new Error(`viewer keyboard focus was not parked: ${JSON.stringify(keyboardFocus)}`); + } + Object.assign(viewer, { keyboardFocus }); + if (target.name === "desktop") { + await page.waitForFunction(() => + document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "false" + ); + await page.mouse.move(target.width / 2, target.height / 2); + await page.mouse.wheel(0, target.height * 1.15); + await page.waitForFunction(() => + (document.querySelector(".image-viewer")?.scrollTop ?? 0) > innerHeight * 0.5 + ); + await page.waitForTimeout(320); + await page.screenshot({ path: "/tmp/pixhelf-viewer-details-desktop.png" }); + Object.assign(viewer, await page.evaluate(() => { + const overlay = document.querySelector(".image-viewer"); + const stage = document.querySelector(".viewer-stage")?.getBoundingClientRect(); + const details = document.querySelector(".viewer-details-page"); + const detailsRect = details?.getBoundingClientRect(); + return { + detailsReachedByWheel: (overlay?.scrollTop ?? 0) > innerHeight * 0.5, + pageAfterDetails: overlay?.getAttribute("data-page") ?? "", + detailsName: details?.getAttribute("data-details-image-name") ?? "", + detailsHeading: document.querySelector(".viewer-details-heading h2")?.textContent ?? "", + stageScrolledAway: stage ? stage.top < -innerHeight * 0.5 : false, + detailsVisible: detailsRect + ? detailsRect.top < innerHeight * 0.5 && detailsRect.bottom > innerHeight * 0.5 + : false, + drawerArtifactsAfterScroll: document.querySelectorAll( + ".viewer-details-sheet, .viewer-details-scrim", + ).length, + }; + })); + await page.locator(".viewer-details-return").click(); + await page.waitForFunction(() => + (document.querySelector(".image-viewer")?.scrollTop ?? 1) < 1 + ); + viewer.returnedToImage = true; + await page.mouse.wheel(0, -target.height); + await page.waitForTimeout(80); + Object.assign(viewer, await page.locator(".image-viewer").evaluate((overlay) => ({ + pageAfterReturn: overlay.dataset.page ?? "", + returnWheelHandoffLocked: + overlay.scrollTop < 1 && overlay.getAttribute("data-zoomed") === "false", + }))); + } if (target.name === "mobile") { const cdp = await page.context().newCDPSession(page); const dispatchTouch = (type, touchPoints) => cdp.send("Input.dispatchTouchEvent", { @@ -309,10 +746,30 @@ try { document.querySelector(".image-viewer")?.getAttribute("data-zoomed") === "true" ); viewer.pinchZoomed = true; + await page.waitForFunction(() => { + const overlay = document.querySelector(".image-viewer"); + const original = document.querySelector(".viewer-native-original"); + return overlay?.getAttribute("data-native-original-active") === "true" + && overlay.getAttribute("data-display-source") === "original" + && original + && Number.parseFloat(getComputedStyle(original).opacity) >= .99; + }, undefined, { timeout: 60_000 }); await page.waitForTimeout(40); viewer.pinchScale = await page.locator(".viewer-media").evaluate((media) => new DOMMatrix(getComputedStyle(media).transform).a ); + viewer.pinchNativeOriginal = await page.evaluate(() => { + const overlay = document.querySelector(".image-viewer"); + const media = document.querySelector(".viewer-media"); + const original = document.querySelector(".viewer-native-original"); + return { + active: overlay?.getAttribute("data-native-original-active") ?? "false", + displaySource: overlay?.getAttribute("data-display-source") ?? "", + visible: original ? Number.parseFloat(getComputedStyle(original).opacity) : 0, + settledWillChange: media ? getComputedStyle(media).willChange : "", + }; + }); + await page.screenshot({ path: "/tmp/pixhelf-viewer-zoom-mobile.png" }); await page.touchscreen.tap(195, 420); await page.waitForTimeout(70); @@ -325,17 +782,121 @@ try { viewer.doubleTapScale = await page.locator(".viewer-media").evaluate((media) => new DOMMatrix(getComputedStyle(media).transform).a ); + viewer.nativeOriginalAfterReset = await page.evaluate(() => { + const overlay = document.querySelector(".image-viewer"); + const media = document.querySelector(".viewer-media"); + const original = document.querySelector(".viewer-native-original"); + return { + active: overlay?.getAttribute("data-native-original-active") ?? "false", + displaySource: overlay?.getAttribute("data-display-source") ?? "", + visible: original ? Number.parseFloat(getComputedStyle(original).opacity) : 0, + settledWillChange: media ? getComputedStyle(media).willChange : "", + }; + }); - const titleBeforeSwipe = await page.locator(".viewer-heading strong").textContent(); + const titleBeforeSwipe = await page.locator(".image-viewer").getAttribute("data-image-name"); 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); + viewer.swipeDragTracking = await page.locator(".viewer-media").evaluate((media) => + new DOMMatrix(getComputedStyle(media).transform).e + ); + viewer.swipeDragWillChange = await page.locator(".viewer-media").evaluate((media) => + getComputedStyle(media).willChange + ); await dispatchTouch("touchEnd", []); + viewer.swipeMotion = await page.evaluate(() => { + const overlay = document.querySelector(".image-viewer"); + const media = document.querySelector(".viewer-media"); + const outgoing = document.querySelector(".viewer-swipe-outgoing"); + return { + mode: overlay?.getAttribute("data-mobile-swipe-motion") ?? "", + direction: overlay?.getAttribute("data-swipe-direction") ?? "", + snapshot: overlay?.getAttribute("data-swipe-snapshot") ?? "", + releaseCostMs: Number.parseFloat( + overlay?.getAttribute("data-swipe-release-cost-ms") ?? "Infinity", + ), + incomingAnimations: media?.getAnimations().length ?? 0, + outgoingAnimations: outgoing?.getAnimations().length ?? 0, + outgoingIgnoresInput: outgoing + ? getComputedStyle(outgoing).pointerEvents === "none" + : false, + }; + }); await page.waitForFunction((title) => - document.querySelector(".viewer-heading strong")?.textContent !== title + document.querySelector(".image-viewer")?.getAttribute("data-image-name") !== title , titleBeforeSwipe); - viewer.swipeNavigation = await page.locator(".viewer-heading strong").textContent(); + viewer.swipeNavigation = await page.locator(".image-viewer").getAttribute("data-image-name"); + await page.waitForFunction(() => + !document.querySelector(".viewer-swipe-outgoing") + && !document.querySelector(".image-viewer")?.hasAttribute("data-swipe-direction") + ); + viewer.swipeMotionCleaned = true; + + await dispatchTouch("touchStart", [{ id: 35, x: 195, y: 500 }]); + await page.waitForTimeout(32); + await dispatchTouch("touchMove", [{ id: 35, x: 195, y: 350 }]); + await page.waitForTimeout(32); + await dispatchTouch("touchMove", [{ id: 35, x: 195, y: 630 }]); + await page.waitForTimeout(32); + await dispatchTouch("touchEnd", []); + await page.waitForFunction(() => + (document.querySelector(".image-viewer")?.scrollTop ?? 1) < 1 + ); + viewer.reversedPageGestureStayedOpen = await page.locator(".image-viewer").evaluate( + (overlay) => overlay.isConnected && overlay.dataset.page === "image", + ); + + await dispatchTouch("touchStart", [{ id: 36, x: 195, y: 690 }]); + await page.waitForTimeout(32); + await dispatchTouch("touchMove", [{ id: 36, x: 195, y: 510 }]); + await page.waitForTimeout(32); + viewer.detailsFollowedTouch = await page.locator(".image-viewer").evaluate((overlay) => + overlay.scrollTop > 100 && + overlay.scrollTop < overlay.clientHeight * 0.5 && + overlay.dataset.page === "transition" + ); + const titleDuringPageGesture = await page.locator(".image-viewer") + .getAttribute("data-image-name"); + await dispatchTouch("touchMove", [{ id: 36, x: 60, y: 510 }]); + await page.waitForTimeout(32); + viewer.pageGestureIgnoredHorizontal = + await page.locator(".image-viewer").getAttribute("data-image-name") + === titleDuringPageGesture; + await dispatchTouch("touchMove", [{ id: 36, x: 195, y: 360 }]); + await page.waitForTimeout(32); + await dispatchTouch("touchEnd", []); + await page.waitForFunction(() => + (document.querySelector(".image-viewer")?.scrollTop ?? 0) > innerHeight * 0.5 + ); + await page.waitForTimeout(320); + await page.screenshot({ path: "/tmp/pixhelf-viewer-details-mobile.png" }); + Object.assign(viewer, await page.evaluate(() => { + const overlay = document.querySelector(".image-viewer"); + const stage = document.querySelector(".viewer-stage")?.getBoundingClientRect(); + const details = document.querySelector(".viewer-details-page"); + const detailsRect = details?.getBoundingClientRect(); + return { + detailsReachedBySwipe: (overlay?.scrollTop ?? 0) > innerHeight * 0.5, + pageAfterDetails: overlay?.getAttribute("data-page") ?? "", + detailsName: details?.getAttribute("data-details-image-name") ?? "", + detailsHeading: document.querySelector(".viewer-details-heading h2")?.textContent ?? "", + stageScrolledAway: stage ? stage.top < -innerHeight * 0.5 : false, + detailsVisible: detailsRect + ? detailsRect.top < innerHeight * 0.5 && detailsRect.bottom > innerHeight * 0.5 + : false, + drawerArtifactsAfterScroll: document.querySelectorAll( + ".viewer-details-sheet, .viewer-details-scrim", + ).length, + }; + })); + await page.locator(".viewer-details-return").click(); + await page.waitForFunction(() => + (document.querySelector(".image-viewer")?.scrollTop ?? 1) < 1 + ); + viewer.returnedToImage = true; + viewer.pageAfterReturn = await page.locator(".image-viewer").getAttribute("data-page"); await dispatchTouch("touchStart", [{ id: 41, x: 195, y: 210 }]); await page.waitForTimeout(32); @@ -346,9 +907,216 @@ try { } else { await page.keyboard.press("Escape"); } + await page.waitForSelector(".viewer-return-layer", { timeout: 5_000 }); + viewer.returnAnimationCreated = await page.locator(".viewer-return-layer").evaluate((layer) => ({ + imageId: layer.getAttribute("data-image-id") ?? "", + phase: layer.getAttribute("data-phase") ?? "", + mediaCount: layer.querySelectorAll(".viewer-return-media").length, + canvasHasPixels: [...layer.querySelectorAll("canvas")] + .every((canvas) => canvas.width > 0 && canvas.height > 0), + maxCanvasEdge: Math.max(0, ...[...layer.querySelectorAll("canvas")] + .flatMap((canvas) => [canvas.width, canvas.height])), + visualReady: [...layer.querySelectorAll(".viewer-return-visual")] + .some((visual) => visual.complete && visual.naturalWidth > 0), + })); await page.waitForSelector(".image-viewer", { state: "detached" }); + await page.waitForFunction(() => + document.querySelector(".viewer-return-layer")?.getAttribute("data-phase") === "flying" + , undefined, { timeout: 5_000 }); + viewer.returnAnimationFlying = await page.locator(".viewer-return-layer").evaluate((layer) => { + const media = layer.querySelector(".viewer-return-media"); + const target = document.querySelector('[data-viewer-return-target="true"]'); + const animation = media?.getAnimations()[0]; + const keyframes = animation?.effect instanceof KeyframeEffect + ? animation.effect.getKeyframes() + : []; + const animatedProperties = [...new Set(keyframes.flatMap((keyframe) => + Object.keys(keyframe).filter((property) => + !["offset", "computedOffset", "easing", "composite"].includes(property) + ) + ))].sort(); + return { + animations: media?.getAnimations().length ?? 0, + targetId: target?.getAttribute("data-image-id") ?? "", + startLatency: Number(layer.getAttribute("data-flying-at") ?? 0) + - Number(layer.getAttribute("data-created-at") ?? 0), + duration: Number(layer.getAttribute("data-duration") ?? 0), + animatedProperties, + }; + }); + const animationViewport = page.viewportSize(); + if (animationViewport) { + await page.setViewportSize({ + width: animationViewport.width + (target.name === "desktop" ? -18 : 10), + height: animationViewport.height, + }); + await page.waitForFunction(() => + Number(document.querySelector(".viewer-return-layer")?.getAttribute("data-retargets") ?? 0) > 0 + ); + await page.waitForFunction(() => + document.querySelector(".viewer-return-layer")?.getAttribute("data-phase") === "flying" + ); + viewer.returnAnimationRetargeted = await page.locator(".viewer-return-layer").evaluate( + (layer) => ({ + count: Number(layer.getAttribute("data-retargets") ?? 0), + animations: layer.querySelector(".viewer-return-media")?.getAnimations().length ?? 0, + }), + ); + } + await page.waitForTimeout(90); + await page.screenshot({ path: `/tmp/pixhelf-viewer-return-${target.name}.png` }); viewer.closed = await page.locator(".image-viewer").count(); viewer.rootRestored = await page.locator("#root").evaluate((root) => !root.inert); + await page.waitForFunction(() => + document.querySelector(".app-shell")?.getAttribute("data-viewer-returning") === "false" + ); + viewer.returnAnchorSettled = true; + viewer.returnAnimationCleared = await page.locator(".viewer-return-layer").count() === 0; + if ( + viewer.returnAnimationCreated.mediaCount !== 1 || + !viewer.returnAnimationCreated.canvasHasPixels || + viewer.returnAnimationCreated.maxCanvasEdge > 1_440 || + !viewer.returnAnimationCreated.visualReady || + viewer.returnAnimationFlying.animations < 1 || + viewer.returnAnimationFlying.targetId !== viewer.returnAnimationCreated.imageId || + viewer.returnAnimationFlying.startLatency > 180 || + viewer.returnAnimationFlying.duration > 280 || + viewer.returnAnimationFlying.animatedProperties.join(",") !== "transform" || + viewer.returnAnimationRetargeted?.count < 1 || + viewer.returnAnimationRetargeted?.animations < 1 || + !viewer.returnAnimationCleared + ) { + throw new Error(`viewer return animation failed: ${JSON.stringify({ + created: viewer.returnAnimationCreated, + flying: viewer.returnAnimationFlying, + cleared: viewer.returnAnimationCleared, + })}`); + } + + const returnProbeStart = await page.evaluate(() => { + const cards = [...document.querySelectorAll(".masonry .image-card")]; + const card = cards.at(-1); + if (!(card instanceof HTMLElement)) return null; + card.scrollIntoView({ block: "center", behavior: "instant" }); + const rect = card.getBoundingClientRect(); + const viewportTop = visualViewport?.offsetTop ?? 0; + const viewportHeight = visualViewport?.height ?? innerHeight; + const viewportBottom = viewportTop + viewportHeight; + const topbarBottom = document.querySelector(".topbar")?.getBoundingClientRect().bottom + ?? viewportTop; + const visibleTop = Math.max(rect.top, viewportTop, topbarBottom); + const visibleBottom = Math.min(rect.bottom, viewportBottom); + const anchorY = visibleBottom > visibleTop + ? (visibleTop + visibleBottom) / 2 + : Math.min(viewportBottom, Math.max(viewportTop, rect.top + rect.height / 2)); + const result = { + imageId: card.dataset.imageId ?? "", + beforeCount: cards.length, + beforeMasonryWidth: document.querySelector(".masonry")?.getBoundingClientRect().width ?? 0, + cardRatio: rect.height > 0 ? (anchorY - rect.top) / rect.height : 0.5, + viewportRatio: (anchorY - viewportTop) / Math.max(1, viewportHeight), + }; + card.dispatchEvent(new MouseEvent("click", { + bubbles: true, + cancelable: true, + button: 0, + clientX: rect.left + rect.width / 2, + clientY: anchorY, + view: window, + })); + return result; + }); + if (!returnProbeStart) throw new Error("viewer return probe could not find a card"); + await page.waitForSelector(".image-viewer"); + const returnViewerStartId = await page.locator(".image-viewer").getAttribute("data-image-id"); + await page.keyboard.press("ArrowRight"); + const resizedWidth = target.name === "desktop" ? 920 : 430; + await page.setViewportSize({ width: resizedWidth, height: target.height }); + await page.waitForFunction((previousWidth) => { + const width = document.querySelector(".masonry")?.getBoundingClientRect().width ?? 0; + return Math.abs(width - previousWidth) > 20; + }, returnProbeStart.beforeMasonryWidth); + await page.waitForFunction(([startId, beforeCount]) => + document.querySelector(".image-viewer")?.getAttribute("data-image-id") !== startId && + document.querySelectorAll(".masonry .image-card").length > beforeCount + , [returnViewerStartId, returnProbeStart.beforeCount], { timeout: 60_000 }); + const returnTargetId = await page.locator(".image-viewer").getAttribute("data-image-id"); + await page.keyboard.press("Escape"); + await page.waitForSelector(".image-viewer", { state: "detached" }); + await page.waitForFunction((imageId) => { + const shell = document.querySelector(".app-shell"); + const active = document.activeElement; + return shell?.getAttribute("data-viewer-returning") === "false" && + active instanceof HTMLElement && active.dataset.imageId === imageId; + }, returnTargetId); + const returnRestoration = await page.evaluate(({ start, targetId }) => { + const card = document.querySelector( + `.masonry .image-card[data-image-id="${CSS.escape(targetId)}"]`, + ); + if (!(card instanceof HTMLElement)) return null; + const rect = card.getBoundingClientRect(); + const viewportTop = visualViewport?.offsetTop ?? 0; + const viewportHeight = visualViewport?.height ?? innerHeight; + const viewportBottom = viewportTop + viewportHeight; + const topbarBottom = document.querySelector(".topbar")?.getBoundingClientRect().bottom + ?? viewportTop; + const safeTop = Math.min(viewportBottom, Math.max(viewportTop, topbarBottom + 8)); + const safeBottom = Math.max(safeTop, viewportBottom - 8); + const expectedAnchorY = Math.min( + safeBottom, + Math.max(safeTop, viewportTop + start.viewportRatio * viewportHeight), + ); + const actualAnchorY = rect.top + rect.height * start.cardRatio; + return { + openedId: start.imageId, + targetId, + cardsBefore: start.beforeCount, + cardsAfter: document.querySelectorAll(".masonry .image-card").length, + currentId: document.activeElement?.getAttribute("data-image-id") ?? "", + visible: rect.bottom > topbarBottom && rect.top < viewportBottom, + anchorDelta: Math.abs(actualAnchorY - expectedAnchorY), + returning: document.querySelector(".app-shell") + ?.getAttribute("data-viewer-returning"), + }; + }, { start: returnProbeStart, targetId: returnTargetId }); + if ( + !returnRestoration || + returnRestoration.openedId === returnRestoration.targetId || + returnRestoration.targetId !== returnRestoration.currentId || + returnRestoration.cardsAfter <= returnRestoration.cardsBefore || + !returnRestoration.visible || + returnRestoration.anchorDelta > 2 || + returnRestoration.returning !== "false" + ) { + throw new Error(`viewer return restoration failed: ${JSON.stringify(returnRestoration)}`); + } + if (viewerReturnOnly) { + const result = { + name: target.name, + resizeStability, + sideButtonNavigation: viewer.sideButtonNavigation, + switchPerformance: viewer.switchPerformance, + rapidSwitch: viewer.rapidSwitch, + returnAnimation: { + created: viewer.returnAnimationCreated, + flying: viewer.returnAnimationFlying, + retargeted: viewer.returnAnimationRetargeted, + cleared: viewer.returnAnimationCleared, + }, + returnRestoration, + browserErrors, + }; + if (browserErrors.length) { + throw new Error(`viewer return browser errors: ${JSON.stringify(browserErrors)}`); + } + results.push(result); + console.log(JSON.stringify(result)); + await page.close(); + continue; + } + await page.setViewportSize({ width: target.width, height: target.height }); + await page.waitForTimeout(120); + await page.evaluate(() => window.scrollTo({ top: 0, left: 0, behavior: "instant" })); const searchToggleBefore = await page.locator(".search-toggle").boundingBox(); await page.locator(".search-toggle").click(); @@ -463,12 +1231,18 @@ try { const url = new URL(request.url()); return url.pathname === "/api/images" && url.searchParams.get("sort") === "name-asc" && - url.searchParams.get("offset") === "0"; + url.searchParams.get("offset") === "0" && + !url.searchParams.has("album") && + !url.searchParams.has("search"); }); await page.locator("a.brand").click(); const homeResponse = await (await homeRequest).response(); await homeResponse?.finished(); - await page.waitForFunction(() => !document.querySelector(".skeleton-grid")); + await page.waitForFunction(() => + document.querySelector(".explore-toggle")?.getAttribute("aria-pressed") === "false" && + document.querySelector(".desktop-sidebar .album-link")?.classList.contains("active") && + document.querySelector("#gallery-search-field input")?.value === "" + , undefined, { timeout: 60_000 }); const homeNavigation = await page.evaluate(() => ({ href: document.querySelector("a.brand")?.getAttribute("href"), galleryPath: document.querySelector( @@ -748,6 +1522,7 @@ try { resizeStability, dialogsAfterImageClick, viewer, + returnRestoration, searchDisclosure, exploration, homeNavigation, @@ -770,11 +1545,23 @@ try { viewer.title !== firstViewerTitle || viewer.fullLoaded !== "true" || !viewer.originalPath.endsWith("/original") || - viewer.renderer !== "bitmap" || - viewer.renderedPixels.some((size) => size <= 0 || size > 4096) || + !["native", "safe-canvas", "viewport-bitmap"].includes(viewer.renderer) || + 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)) || + (target.name === "mobile" && viewer.renderer !== "viewport-bitmap") || viewer.prefetchedOriginals < 2 || viewer.previewRequests !== 0 || viewer.fullscreenButtons !== 1 || + viewer.detailsPages !== 1 || + viewer.scrollCues !== 1 || + viewer.headingModules !== 0 || + !viewer.unifiedControls || + viewer.zoomControls !== 0 || + !viewer.scrollCueArrowOnly || + viewer.drawerArtifacts !== 0 || + viewer.scrollMode !== "continuous" || + !viewer.continuousLayout || viewer.gestureHelp !== 0 || viewer.loadingStatus !== 0 || viewer.loadingCopyVisible || @@ -785,21 +1572,97 @@ try { !viewer.closeFocused || !viewer.mediaContained || !viewer.position.includes("/") || - viewer.zoomed !== "150%" || + viewer.zoomed !== "true" || Math.abs(viewer.zoomScale - 1.5) > 0.02 || + !viewer.zoomKeptImagePage || + !viewer.zoomLockedPageScroll || !viewer.keyboardNavigation || viewer.keyboardNavigation === firstViewerTitle || + !viewer.keyboardFocus?.parkedOnViewer || + viewer.keyboardFocus.visibleControlFocusRings !== 0 || + !viewer.switchPerformance?.focusedOnViewer || + viewer.switchPerformance.visibleControlFocusRings !== 0 || + viewer.switchPerformance.firstFrameSource !== ( + target.name === "mobile" ? "viewport-bitmap" : "original" + ) || + viewer.switchPerformance.mediaAnimations !== 0 || + viewer.switchPerformance.dispatchDuration > 80 || + viewer.switchPerformance.firstFrameDuration > 100 || + !viewerChrome.scrollCuePresentation?.borderless || + !viewerChrome.scrollCuePresentation?.transparent || + viewerChrome.scrollCuePresentation.width < 88 || + !viewerChrome.scrollCuePresentation?.clearsMedia || + (target.name === "desktop" && ( + !viewer.detailsReachedByWheel || + viewer.detailsName !== viewer.keyboardNavigation || + !viewer.zoomWheelHandoffLocked || + viewer.pageAfterDetails !== "details" || + viewer.detailsHeading !== "图片详情" || + !viewer.stageScrolledAway || + !viewer.detailsVisible || + viewer.drawerArtifactsAfterScroll !== 0 || + !viewer.returnedToImage || + viewer.pageAfterReturn !== "image" || + !viewer.returnWheelHandoffLocked + )) || (target.name === "mobile" && ( + viewer.nativeOriginalLoaded !== "true" || + viewer.nativeOriginalActive !== "false" || + viewer.nativeOriginalCount !== 1 || + !viewer.nativeOriginalPath.endsWith("/original") || + viewer.nativeOriginalVisible > .01 || + viewer.nativeOriginalPixels.some((size, index) => size <= viewer.renderedPixels[index]) || + viewer.displaySource !== "viewport-bitmap" || + viewer.mediaWillChange !== "auto" || !viewer.pinchZoomed || viewer.pinchScale < 1.5 || + viewer.pinchNativeOriginal?.active !== "true" || + viewer.pinchNativeOriginal.displaySource !== "original" || + viewer.pinchNativeOriginal.visible < .99 || + viewer.pinchNativeOriginal.settledWillChange !== "auto" || !viewer.doubleTapReset || Math.abs(viewer.doubleTapScale - 1) > 0.02 || + viewer.nativeOriginalAfterReset?.active !== "false" || + viewer.nativeOriginalAfterReset.displaySource !== "viewport-bitmap" || + viewer.nativeOriginalAfterReset.visible > .01 || + viewer.nativeOriginalAfterReset.settledWillChange !== "auto" || !viewer.swipeNavigation || viewer.swipeNavigation === viewer.keyboardNavigation || + viewer.swipeDragTracking > -190 || + viewer.swipeDragTracking < -225 || + viewer.swipeDragWillChange !== "transform" || + viewer.swipeMotion?.mode !== "interruptible" || + viewer.swipeMotion.direction !== "next" || + viewer.swipeMotion.snapshot !== "prepared" || + viewer.swipeMotion.releaseCostMs > 12 || + viewer.swipeMotion.incomingAnimations < 1 || + viewer.swipeMotion.outgoingAnimations < 1 || + !viewer.swipeMotion.outgoingIgnoresInput || + !viewer.swipeMotionCleaned || + !viewer.reversedPageGestureStayedOpen || + !viewer.pageGestureIgnoredHorizontal || + !viewer.detailsFollowedTouch || + !viewer.detailsReachedBySwipe || + viewer.pageAfterDetails !== "details" || + viewer.detailsName !== viewer.swipeNavigation || + viewer.detailsHeading !== "图片详情" || + !viewer.stageScrolledAway || + !viewer.detailsVisible || + viewer.drawerArtifactsAfterScroll !== 0 || + !viewer.returnedToImage || + viewer.pageAfterReturn !== "image" || !viewer.closedByGesture )) || viewer.closed !== 0 || !viewer.rootRestored || + !viewer.returnAnchorSettled || + !returnRestoration || + returnRestoration.openedId === returnRestoration.targetId || + returnRestoration.targetId !== returnRestoration.currentId || + returnRestoration.cardsAfter <= returnRestoration.cardsBefore || + !returnRestoration.visible || + returnRestoration.anchorDelta > 2 || + returnRestoration.returning !== "false" || searchDisclosure.expanded !== "true" || !searchDisclosure.focused || searchDisclosure.panelRole !== "group" || @@ -845,7 +1708,10 @@ try { resizeStability.skeletonSeen || resizeStability.imagePageRequests !== 0 || resizeStability.brokenVisibleImages !== 0 || - resizeStability.columns !== 5 + resizeStability.columns !== 5 || + !resizeStability.anchorFocused || + resizeStability.anchorDeltas.length !== 4 || + resizeStability.anchorDeltas.some((delta) => !Number.isFinite(delta) || delta > 2) )) || !layout.galleryPath.startsWith("/") || !layout.galleryCount.endsWith("张图片") || diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 392cc21..97fc0f1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,13 +22,18 @@ import { useState, } from "preact/hooks"; import { ToolbarPopover } from "./ToolbarPopover"; -import { ImageViewer, preloadOriginalImage } from "./ImageViewer"; +import { ImageViewer } from "./ImageViewer"; import { getGallery, getImages, getStatus, takeInitialBootstrap } from "./api"; import type { GalleryImage, GallerySummary, ThumbnailStatus, } from "./types"; +import { + getDecodedViewerOriginal, + preloadOriginalImage, + prepareViewerImages, +} from "./viewerAssets"; const PAGE_SIZE = 60; const CARD_PREFETCH_MARGIN = "1200px 0px"; @@ -38,6 +43,9 @@ const SIDEBAR_STORAGE_KEY = "pixhelf.sidebar-collapsed"; const GALLERY_POLL_INTERVAL_MS = 10_000; const IMAGE_RETRY_DELAYS_MS = [1_000, 3_000] as const; const MOBILE_NAV_EXIT_MS = 240; +const VIEWER_RETURN_SETTLE_MS = 32; +const VIEWER_RETURN_TIMEOUT_MS = 480; +const VIEWER_RETURN_ANIMATION_MS = 260; const COUNT_FORMATTER = new Intl.NumberFormat("zh-CN"); const INITIAL_BOOTSTRAP = takeInitialBootstrap(); @@ -47,12 +55,279 @@ type ImagePageState = { nextOffset: number | null; }; +type ViewerAnchor = { + cardRatio: number; + viewportRatio: number; + fallbackScrollY: number; +}; + +type ViewerReturnRequest = ViewerAnchor & { + imageId: string; + sequence: number; +}; + +type MasonryViewportAnchor = ViewerAnchor & { + imageId: string; +}; + +type ViewerReturnFlight = { + layer: HTMLDivElement; + backdrop: HTMLDivElement; + media: HTMLElement; + animations: Animation[]; +}; + const EMPTY_IMAGE_PAGE: ImagePageState = { images: [], total: 0, nextOffset: null, }; +function clampValue(value: number, minimum: number, maximum: number): number { + return Math.min(maximum, Math.max(minimum, value)); +} + +function imageCardById(imageId: string): HTMLElement | null { + return document.querySelector( + `.masonry .image-card[data-image-id="${CSS.escape(imageId)}"]`, + ); +} + +function visualViewportBounds(): { top: number; height: number } { + const viewport = window.visualViewport; + return { + top: viewport?.offsetTop ?? 0, + height: Math.max(1, viewport?.height ?? window.innerHeight), + }; +} + +function captureViewerAnchor(card: HTMLElement, pointerY?: number): ViewerAnchor { + const rect = card.getBoundingClientRect(); + const viewport = visualViewportBounds(); + const viewportBottom = viewport.top + viewport.height; + const topbarBottom = document.querySelector(".topbar")?.getBoundingClientRect().bottom + ?? viewport.top; + const visibleTop = Math.max(rect.top, viewport.top, topbarBottom); + const visibleBottom = Math.min(rect.bottom, viewportBottom); + const visibleCenter = visibleBottom > visibleTop + ? (visibleTop + visibleBottom) / 2 + : clampValue(rect.top + rect.height / 2, viewport.top, viewportBottom); + const requestedPoint = pointerY !== undefined && Number.isFinite(pointerY) + ? pointerY + : visibleCenter; + const anchorY = clampValue( + requestedPoint, + Math.min(visibleTop, visibleBottom), + Math.max(visibleTop, visibleBottom), + ); + return { + cardRatio: rect.height > 0 + ? clampValue((anchorY - rect.top) / rect.height, 0, 1) + : 0.5, + viewportRatio: clampValue((anchorY - viewport.top) / viewport.height, 0, 1), + fallbackScrollY: window.scrollY, + }; +} + +function captureMasonryViewportAnchor(masonry: HTMLElement): MasonryViewportAnchor | null { + if (window.scrollY <= 1) return null; + const viewport = visualViewportBounds(); + const viewportBottom = viewport.top + viewport.height; + const topbarBottom = document.querySelector(".topbar")?.getBoundingClientRect().bottom + ?? viewport.top; + const safeTop = Math.min(viewportBottom, Math.max(viewport.top, topbarBottom + 8)); + const safeBottom = Math.max(safeTop, viewportBottom - 8); + const referenceY = safeTop + (safeBottom - safeTop) * .45; + const viewportCenterX = (window.visualViewport?.offsetLeft ?? 0) + + (window.visualViewport?.width ?? window.innerWidth) / 2; + const cards = [...masonry.querySelectorAll(".image-card")] + .map((card) => ({ card, rect: card.getBoundingClientRect() })) + .filter(({ rect }) => rect.bottom > safeTop && rect.top < safeBottom); + if (!cards.length) return null; + + const focusedCard = document.activeElement instanceof HTMLElement + ? document.activeElement.closest(".image-card") + : null; + const focused = focusedCard + ? cards.find(({ card }) => card === focusedCard) + : undefined; + const selected = focused ?? cards.reduce((best, candidate) => { + const verticalDistance = candidate.rect.top <= referenceY + && candidate.rect.bottom >= referenceY + ? 0 + : Math.min( + Math.abs(candidate.rect.top - referenceY), + Math.abs(candidate.rect.bottom - referenceY), + ); + const bestVerticalDistance = best.rect.top <= referenceY + && best.rect.bottom >= referenceY + ? 0 + : Math.min( + Math.abs(best.rect.top - referenceY), + Math.abs(best.rect.bottom - referenceY), + ); + if (verticalDistance !== bestVerticalDistance) { + return verticalDistance < bestVerticalDistance ? candidate : best; + } + const horizontalDistance = Math.abs( + candidate.rect.left + candidate.rect.width / 2 - viewportCenterX, + ); + const bestHorizontalDistance = Math.abs( + best.rect.left + best.rect.width / 2 - viewportCenterX, + ); + return horizontalDistance < bestHorizontalDistance ? candidate : best; + }); + const visibleTop = Math.max(safeTop, selected.rect.top); + const visibleBottom = Math.min(safeBottom, selected.rect.bottom); + const anchorY = focused + ? (visibleTop + visibleBottom) / 2 + : clampValue(referenceY, visibleTop, visibleBottom); + return { + imageId: selected.card.dataset.imageId ?? "", + cardRatio: selected.rect.height > 0 + ? clampValue((anchorY - selected.rect.top) / selected.rect.height, 0, 1) + : .5, + viewportRatio: clampValue((anchorY - viewport.top) / viewport.height, 0, 1), + fallbackScrollY: window.scrollY, + }; +} + +function restoreMasonryViewportAnchor( + masonry: HTMLElement, + anchor: MasonryViewportAnchor, +): void { + const card = masonry.querySelector( + `.image-card[data-image-id="${CSS.escape(anchor.imageId)}"]`, + ); + if (!card) { + scrollWindowImmediately(anchor.fallbackScrollY); + return; + } + const rect = card.getBoundingClientRect(); + const viewport = visualViewportBounds(); + const viewportBottom = viewport.top + viewport.height; + const topbarBottom = document.querySelector(".topbar")?.getBoundingClientRect().bottom + ?? viewport.top; + const safeTop = Math.min(viewportBottom, Math.max(viewport.top, topbarBottom + 8)); + const safeBottom = Math.max(safeTop, viewportBottom - 8); + const desiredY = clampValue( + viewport.top + anchor.viewportRatio * viewport.height, + safeTop, + safeBottom, + ); + const actualY = rect.top + rect.height * anchor.cardRatio; + const documentHeight = Math.max( + document.documentElement.scrollHeight, + document.body.scrollHeight, + ); + const maximumScroll = Math.max(0, documentHeight - window.innerHeight); + scrollWindowImmediately(clampValue( + window.scrollY + actualY - desiredY, + 0, + maximumScroll, + )); +} + +function scrollWindowImmediately(top: number): void { + const root = document.documentElement; + const previousBehavior = root.style.scrollBehavior; + root.style.scrollBehavior = "auto"; + window.scrollTo({ top, left: window.scrollX, behavior: "auto" }); + root.style.scrollBehavior = previousBehavior; +} + +function disposeViewerReturnFlight(flight: ViewerReturnFlight | null): void { + if (!flight) return; + for (const animation of flight.animations) animation.cancel(); + flight.layer.remove(); +} + +function createViewerReturnFlight(): ViewerReturnFlight | null { + const viewer = document.querySelector(".image-viewer"); + const media = viewer?.querySelector(".viewer-media"); + if (!viewer || !media) return null; + + const viewportTop = window.visualViewport?.offsetTop ?? 0; + const viewportHeight = window.visualViewport?.height ?? window.innerHeight; + const viewportBottom = viewportTop + viewportHeight; + const mediaRect = media.getBoundingClientRect(); + const detailsPreview = viewer.querySelector(".viewer-details-summary img"); + const detailsRect = detailsPreview?.getBoundingClientRect(); + const mediaVisible = mediaRect.bottom > viewportTop && mediaRect.top < viewportBottom; + const detailsVisible = detailsRect + ? detailsRect.bottom > viewportTop && detailsRect.top < viewportBottom + : false; + const sourceRect = !mediaVisible && detailsVisible ? detailsRect! : mediaRect; + if (sourceRect.width <= 0 || sourceRect.height <= 0) return null; + + const layer = document.createElement("div"); + layer.className = "viewer-return-layer"; + layer.dataset.phase = "settling"; + layer.dataset.imageId = viewer.dataset.imageId ?? ""; + layer.dataset.createdAt = String(performance.now()); + const backdrop = document.createElement("div"); + backdrop.className = "viewer-return-backdrop"; + const clone = media.cloneNode(true) as HTMLElement; + clone.className = "viewer-media viewer-return-media"; + clone.removeAttribute("ref"); + clone.setAttribute("aria-hidden", "true"); + Object.assign(clone.style, { + position: "fixed", + left: `${sourceRect.left}px`, + top: `${sourceRect.top}px`, + width: `${sourceRect.width}px`, + height: `${sourceRect.height}px`, + transform: "none", + transformOrigin: "top left", + transition: "none", + animation: "none", + opacity: "1", + borderRadius: "6px", + boxShadow: "0 12px 38px rgba(0, 0, 0, .28)", + }); + + const thumbnailSource = media.querySelector(".viewer-thumbnail"); + const originalSource = media.querySelector(".viewer-original"); + const originalUrl = originalSource instanceof HTMLImageElement + ? originalSource.currentSrc || originalSource.src + : originalSource?.dataset.originalUrl ?? ""; + const decodedOriginal = originalUrl ? getDecodedViewerOriginal(originalUrl) : null; + const thumbnail = thumbnailSource?.cloneNode(false) as HTMLImageElement | undefined; + const original = viewer.dataset.fullLoaded === "true" + ? (decodedOriginal + ?? (originalSource instanceof HTMLImageElement && originalSource.complete + ? originalSource + : null))?.cloneNode(false) as HTMLImageElement | undefined + : undefined; + const visualLayers = [thumbnail, original].filter( + (visual): visual is HTMLImageElement => Boolean(visual), + ); + clone.replaceChildren(...visualLayers); + visualLayers.forEach((visual, index) => { + visual.className = index === visualLayers.length - 1 && original + ? "viewer-return-visual viewer-return-original" + : "viewer-return-visual viewer-return-thumbnail"; + visual.alt = ""; + visual.setAttribute("aria-hidden", "true"); + visual.draggable = false; + Object.assign(visual.style, { + position: "absolute", + inset: "0", + width: "100%", + height: "100%", + objectFit: "contain", + opacity: "1", + filter: "none", + transform: "none", + transition: "none", + }); + }); + + layer.append(backdrop, clone); + document.body.append(layer); + return { layer, backdrop, media: clone, animations: [] }; +} + function initialSidebarCollapsed(): boolean { try { return window.localStorage.getItem(SIDEBAR_STORAGE_KEY) === "true"; @@ -224,6 +499,7 @@ function App() { const [mobileNavOpen, setMobileNavOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(initialSidebarCollapsed); const [viewerImageId, setViewerImageId] = useState(null); + const [viewerReturnRequest, setViewerReturnRequest] = useState(null); const [reloadToken, setReloadToken] = useState(0); const compactLayout = useMediaQuery("(max-width: 720px)"); const mobileNavMounted = useDelayedUnmount(mobileNavOpen, MOBILE_NAV_EXIT_MS); @@ -235,12 +511,25 @@ function App() { const skipInitialImagesRef = useRef(Boolean(INITIAL_BOOTSTRAP)); const loadMoreControllerRef = useRef(null); const loadMorePromiseRef = useRef | null>(null); + const viewerImageIdRef = useRef(null); + const viewerAnchorRef = useRef(null); + const viewerReturnSequenceRef = useRef(0); + const viewerReturnFlightRef = useRef(null); const { images, total, nextOffset } = imagePage; useLayoutEffect(() => { window.scrollTo({ top: 0, left: 0, behavior: "auto" }); }, [album, debouncedSearch, exploreSeed]); + useLayoutEffect(() => { + viewerImageIdRef.current = viewerImageId; + }, [viewerImageId]); + + useEffect(() => () => { + disposeViewerReturnFlight(viewerReturnFlightRef.current); + viewerReturnFlightRef.current = null; + }, []); + useEffect(() => { if (!mobileNavMounted) return; const previousOverflow = document.body.style.overflow; @@ -462,29 +751,285 @@ function App() { ? images.findIndex((image) => image.id === viewerImageId) : -1; + const openViewer = useCallback(( + imageId: string, + card: HTMLElement, + pointerY?: number, + ) => { + disposeViewerReturnFlight(viewerReturnFlightRef.current); + viewerReturnFlightRef.current = null; + viewerAnchorRef.current = captureViewerAnchor(card, pointerY); + prepareViewerImages(images, images.findIndex((image) => image.id === imageId)); + setViewerReturnRequest(null); + viewerImageIdRef.current = imageId; + setViewerImageId(imageId); + }, [images]); + + const closeViewer = useCallback(() => { + const currentImageId = viewerImageIdRef.current; + if (!currentImageId) return; + disposeViewerReturnFlight(viewerReturnFlightRef.current); + viewerReturnFlightRef.current = createViewerReturnFlight(); + const anchor = viewerAnchorRef.current ?? { + cardRatio: 0.5, + viewportRatio: 0.5, + fallbackScrollY: window.scrollY, + }; + viewerReturnSequenceRef.current += 1; + setViewerReturnRequest({ + ...anchor, + imageId: currentImageId, + sequence: viewerReturnSequenceRef.current, + }); + viewerImageIdRef.current = null; + setViewerImageId(null); + }, []); + + useLayoutEffect(() => { + if (viewerImageId || !viewerReturnRequest) return; + const request = viewerReturnRequest; + const requestFlight = viewerReturnFlightRef.current; + let frame = 0; + let settleTimer = 0; + let timeoutTimer = 0; + let stopped = false; + let animating = false; + let finished = false; + let animationGeneration = 0; + let animationTarget: HTMLElement | null = null; + let observedCard: HTMLElement | null = null; + let observedMasonry: HTMLElement | null = null; + let resizeObserver: ResizeObserver | null = null; + let resizeObserverPrimed = false; + + const observeLayout = (card: HTMLElement) => { + const masonry = card.closest(".masonry"); + if (resizeObserver && card !== observedCard) { + if (observedCard) resizeObserver.unobserve(observedCard); + resizeObserver.observe(card); + observedCard = card; + } + if (resizeObserver && masonry && masonry !== observedMasonry) { + if (observedMasonry) resizeObserver.unobserve(observedMasonry); + resizeObserver.observe(masonry); + observedMasonry = masonry; + } + }; + + const alignToCard = (): HTMLElement | null => { + const card = imageCardById(request.imageId); + if (!card) return null; + observeLayout(card); + + const rect = card.getBoundingClientRect(); + const viewport = visualViewportBounds(); + const viewportBottom = viewport.top + viewport.height; + const topbarBottom = document.querySelector(".topbar")?.getBoundingClientRect().bottom + ?? viewport.top; + const safeTop = Math.min( + viewportBottom, + Math.max(viewport.top, topbarBottom + 8), + ); + const safeBottom = Math.max(safeTop, viewportBottom - 8); + const desiredViewportY = clampValue( + viewport.top + request.viewportRatio * viewport.height, + safeTop, + safeBottom, + ); + const cardAnchorY = rect.top + rect.height * request.cardRatio; + const documentHeight = Math.max( + document.documentElement.scrollHeight, + document.body.scrollHeight, + ); + const maximumScroll = Math.max(0, documentHeight - window.innerHeight); + const targetScroll = clampValue( + window.scrollY + cardAnchorY - desiredViewportY, + 0, + maximumScroll, + ); + if (Math.abs(targetScroll - window.scrollY) > 0.5) { + scrollWindowImmediately(targetScroll); + } + return card; + }; + + const finalize = (card: HTMLElement | null) => { + if (finished) return; + finished = true; + stopped = true; + disposeViewerReturnFlight(requestFlight); + if (viewerReturnFlightRef.current === requestFlight) viewerReturnFlightRef.current = null; + if (card?.isConnected) card.focus({ preventScroll: true }); + setViewerReturnRequest((current) => { + if (current?.sequence !== request.sequence) return current; + viewerAnchorRef.current = null; + return null; + }); + }; + + const pauseAnimationForLayout = () => { + const flight = requestFlight; + if (!animating || !flight) return; + const mediaRect = flight.media.getBoundingClientRect(); + const backdropOpacity = window.getComputedStyle(flight.backdrop).opacity; + animationGeneration += 1; + for (const animation of flight.animations) animation.cancel(); + flight.animations.length = 0; + Object.assign(flight.media.style, { + left: `${mediaRect.left}px`, + top: `${mediaRect.top}px`, + width: `${mediaRect.width}px`, + height: `${mediaRect.height}px`, + transform: "none", + }); + flight.backdrop.style.opacity = backdropOpacity; + if (animationTarget) delete animationTarget.dataset.viewerReturnTarget; + animationTarget = null; + animating = false; + flight.layer.dataset.phase = "settling"; + flight.layer.dataset.retargets = String( + Number.parseInt(flight.layer.dataset.retargets ?? "0", 10) + 1, + ); + }; + + const complete = () => { + if (stopped || animating) return; + const card = alignToCard(); + if (!card) { + const maximumScroll = Math.max( + 0, + Math.max(document.documentElement.scrollHeight, document.body.scrollHeight) + - window.innerHeight, + ); + scrollWindowImmediately(clampValue(request.fallbackScrollY, 0, maximumScroll)); + } + + const flight = requestFlight; + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (!flight || reducedMotion || typeof flight.media.animate !== "function") { + finalize(card); + return; + } + + animating = true; + const generation = ++animationGeneration; + flight.layer.dataset.phase = card ? "flying" : "fading"; + flight.layer.dataset.flyingAt = String(performance.now()); + flight.layer.dataset.duration = String(VIEWER_RETURN_ANIMATION_MS); + const animations: Animation[] = []; + if (card) { + const sourceRect = flight.media.getBoundingClientRect(); + const targetRect = card.getBoundingClientRect(); + const scaleX = targetRect.width / Math.max(1, sourceRect.width); + const scaleY = targetRect.height / Math.max(1, sourceRect.height); + card.dataset.viewerReturnTarget = "true"; + animationTarget = card; + animations.push(flight.media.animate([ + { transform: "translate3d(0, 0, 0) scale(1, 1)" }, + { + transform: `translate3d(${targetRect.left - sourceRect.left}px, ${targetRect.top - sourceRect.top}px, 0) scale(${scaleX}, ${scaleY})`, + }, + ], { + duration: VIEWER_RETURN_ANIMATION_MS, + easing: "cubic-bezier(.22, 1, .36, 1)", + fill: "forwards", + })); + } else { + animations.push(flight.media.animate([ + { opacity: 1, transform: "scale(1)" }, + { opacity: 0, transform: "scale(.98)" }, + ], { + duration: 120, + easing: "ease-out", + fill: "forwards", + })); + } + animations.push(flight.backdrop.animate([ + { opacity: Number.parseFloat(window.getComputedStyle(flight.backdrop).opacity) || 0 }, + { opacity: 0 }, + ], { + duration: card ? 165 : 120, + easing: "cubic-bezier(.2, .8, .2, 1)", + fill: "forwards", + })); + flight.animations.push(...animations); + void Promise.allSettled(animations.map((animation) => animation.finished)).then(() => { + if ( + viewerReturnFlightRef.current !== flight || + generation !== animationGeneration + ) return; + if (card) delete card.dataset.viewerReturnTarget; + finalize(card); + }); + }; + + const schedule = () => { + if (stopped) return; + if (animating) pauseAnimationForLayout(); + if (frame) return; + frame = window.requestAnimationFrame(() => { + frame = 0; + alignToCard(); + window.clearTimeout(settleTimer); + settleTimer = window.setTimeout(complete, VIEWER_RETURN_SETTLE_MS); + }); + }; + + resizeObserver = new ResizeObserver(() => { + if (!resizeObserverPrimed) { + resizeObserverPrimed = true; + return; + } + schedule(); + }); + const mutationObserver = new MutationObserver(schedule); + const masonry = document.querySelector(".masonry"); + if (masonry) mutationObserver.observe(masonry, { childList: true }); + window.addEventListener("resize", schedule); + window.visualViewport?.addEventListener("resize", schedule); + schedule(); + timeoutTimer = window.setTimeout(complete, VIEWER_RETURN_TIMEOUT_MS); + + return () => { + stopped = true; + finished = true; + if (animationTarget) delete animationTarget.dataset.viewerReturnTarget; + if (frame) window.cancelAnimationFrame(frame); + window.clearTimeout(settleTimer); + window.clearTimeout(timeoutTimer); + resizeObserver?.disconnect(); + mutationObserver.disconnect(); + window.removeEventListener("resize", schedule); + window.visualViewport?.removeEventListener("resize", schedule); + }; + }, [viewerImageId, viewerReturnRequest]); + 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); + const currentImageId = viewerImageIdRef.current; + if (!currentImageId) return; + const currentIndex = images.findIndex((image) => image.id === currentImageId); if (currentIndex < 0) return; const target = images[currentIndex + direction]; if (target) { + viewerImageIdRef.current = target.id; setViewerImageId(target.id); return; } if (direction < 0 || nextOffset === null) return; - const startingId = viewerImageId; + const startingId = currentImageId; void loadMore().then((incoming) => { const next = incoming[0]; - if (!next) return; - setViewerImageId((current) => current === startingId ? next.id : current); + if (!next || viewerImageIdRef.current !== startingId) return; + viewerImageIdRef.current = next.id; + setViewerImageId(next.id); }); - }, [images, loadMore, nextOffset, viewerImageId]); + }, [images, loadMore, nextOffset]); const galleryPath = `/${album.replace(/^\/+/, "")}`; const chooseAlbum = (path: string) => { @@ -516,6 +1061,8 @@ function App() { className="app-shell" data-sidebar-collapsed={sidebarCollapsed} data-mobile-navigation-open={mobileNavOpen} + data-viewer-returning={Boolean(viewerReturnRequest)} + data-viewer-return-image-id={viewerReturnRequest?.imageId} >
) : (
@@ -609,7 +1157,7 @@ function App() { hasMore={nextOffset !== null} loadingMore={loadingMore} onNavigate={navigateViewer} - onClose={() => setViewerImageId(null)} + onClose={closeViewer} /> )}
@@ -940,12 +1488,16 @@ type MasonryMetrics = { function useMasonryMetrics( ref: RefObject, initialColumnCount: number, + onBeforeChange?: () => void, ): MasonryMetrics { const [metrics, setMetrics] = useState({ width: 0, columnCount: initialColumnCount, gap: 6, }); + const metricsRef = useRef(metrics); + const beforeChangeRef = useRef(onBeforeChange); + beforeChangeRef.current = onBeforeChange; useLayoutEffect(() => { const element = ref.current; if (!element) return; @@ -961,13 +1513,16 @@ function useMasonryMetrics( const next = Math.floor((width + gap) / (minimumCardWidth + gap)); const roundedWidth = Math.round(width * 100) / 100; const columnCount = Math.min(6, Math.max(2, next)); - setMetrics((current) => ( + const current = metricsRef.current; + if ( current.width === roundedWidth && current.columnCount === columnCount && current.gap === gap - ? current - : { width: roundedWidth, columnCount, gap } - )); + ) return; + if (current.width > 0) beforeChangeRef.current?.(); + const nextMetrics = { width: roundedWidth, columnCount, gap }; + metricsRef.current = nextMetrics; + setMetrics(nextMetrics); }; const schedule = (width: number) => { pendingWidth = width; @@ -989,18 +1544,90 @@ function useMasonryMetrics( return metrics; } -function MasonryGallery({ +const MasonryGallery = memo(function MasonryGallery({ images, initialColumnCount, + preserveViewport, onOpen, }: { images: GalleryImage[]; initialColumnCount: number; - onOpen: (id: string) => void; + preserveViewport: boolean; + onOpen: (id: string, card: HTMLElement, pointerY?: number) => void; }) { const ref = useRef(null); + const resizeAnchorRef = useRef(null); + const stableViewportAnchorRef = useRef(null); + const resizeGuardUntilRef = useRef(0); const [activeNameId, setActiveNameId] = useState(null); - const { width, columnCount, gap } = useMasonryMetrics(ref, initialColumnCount); + const captureResizeAnchor = useCallback(() => { + const masonry = ref.current; + resizeAnchorRef.current = preserveViewport && masonry + ? stableViewportAnchorRef.current ?? captureMasonryViewportAnchor(masonry) + : null; + }, [preserveViewport]); + const { width, columnCount, gap } = useMasonryMetrics( + ref, + initialColumnCount, + captureResizeAnchor, + ); + + useLayoutEffect(() => { + const anchor = resizeAnchorRef.current; + const masonry = ref.current; + resizeAnchorRef.current = null; + if (anchor && masonry && preserveViewport) { + restoreMasonryViewportAnchor(masonry, anchor); + } + stableViewportAnchorRef.current = preserveViewport && masonry + ? captureMasonryViewportAnchor(masonry) + : null; + }, [columnCount, gap, images, preserveViewport, width]); + + useEffect(() => { + const masonry = ref.current; + if (!masonry || !preserveViewport) { + stableViewportAnchorRef.current = null; + return; + } + let frame = 0; + const rememberViewportAnchor = () => { + frame = 0; + if ( + resizeAnchorRef.current + || performance.now() < resizeGuardUntilRef.current + ) return; + stableViewportAnchorRef.current = captureMasonryViewportAnchor(masonry); + }; + const scheduleViewportAnchor = () => { + if (frame) return; + frame = window.requestAnimationFrame(rememberViewportAnchor); + }; + const rememberFocusedAnchor = () => { + if (frame) window.cancelAnimationFrame(frame); + rememberViewportAnchor(); + }; + const guardViewportResize = () => { + resizeGuardUntilRef.current = performance.now() + 120; + if (frame) { + window.cancelAnimationFrame(frame); + frame = 0; + } + }; + + rememberViewportAnchor(); + window.addEventListener("scroll", scheduleViewportAnchor, { passive: true }); + window.addEventListener("resize", guardViewportResize); + window.visualViewport?.addEventListener("resize", guardViewportResize); + masonry.addEventListener("focusin", rememberFocusedAnchor); + return () => { + if (frame) window.cancelAnimationFrame(frame); + window.removeEventListener("scroll", scheduleViewportAnchor); + window.removeEventListener("resize", guardViewportResize); + window.visualViewport?.removeEventListener("resize", guardViewportResize); + masonry.removeEventListener("focusin", rememberFocusedAnchor); + }; + }, [preserveViewport]); const showName = useCallback((id: string) => setActiveNameId(id), []); @@ -1054,7 +1681,7 @@ function MasonryGallery({ ))} ); -} +}); const ImageCard = memo(function ImageCard({ image, @@ -1071,7 +1698,7 @@ const ImageCard = memo(function ImageCard({ highPriority: boolean; nameVisible: boolean; onNameTouch: (id: string) => void; - onOpen: (id: string) => void; + onOpen: (id: string, card: HTMLElement, pointerY?: number) => void; }) { const alreadyReady = READY_THUMBNAIL_IDS.has(image.id); const [loaded, setLoaded] = useState(alreadyReady); @@ -1153,11 +1780,15 @@ const ImageCard = memo(function ImageCard({ if (event.pointerType !== "mouse") onNameTouch(image.id); }} onFocus={() => preloadOriginalImage(image)} - onClick={() => onOpen(image.id)} + onClick={(event) => onOpen( + image.id, + event.currentTarget, + event.detail > 0 ? event.clientY : undefined, + )} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); - onOpen(image.id); + onOpen(image.id, event.currentTarget); }} role="button" tabIndex={0} diff --git a/frontend/src/ImageViewer.tsx b/frontend/src/ImageViewer.tsx index 8616fcb..36397e1 100644 --- a/frontend/src/ImageViewer.tsx +++ b/frontend/src/ImageViewer.tsx @@ -4,24 +4,39 @@ import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; import { ChevronLeft, ChevronRight, + ChevronUp, + ChevronsDown, Download, LoaderCircle, Maximize2, - Minus, Minimize2, - Plus, RefreshCw, X, } from "./icons"; import type { GalleryImage } from "./types"; +import { + getViewerOriginalAsset, + getViewerOriginalStatus, + getViewerThumbnailStatus, + getViewerViewportRenderAsset, + getViewerViewportRenderStatus, + prepareViewerImages, + viewerOriginalUrl, + viewerThumbnailUrl, +} from "./viewerAssets"; 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 VIEWER_SCROLL_EPSILON = 2; +const WHEEL_HANDOFF_DELAY_MS = 220; const MAX_RENDER_EDGE = 4096; +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; type ViewerTransform = { scale: number; @@ -37,10 +52,15 @@ type PointerPoint = { type GestureStart = { point: PointerPoint; transform: ViewerTransform; + scrollTop: number; startedAt: number; pointerType: string; }; +type GestureMode = "pan" | "navigate" | "page" | "dismiss" | "pinch"; + +type ViewerPage = "image" | "transition" | "details"; + type PinchStart = { distance: number; midpoint: PointerPoint; @@ -54,9 +74,22 @@ type OriginalLoadState = { failed: boolean; }; -type NetworkInformation = { - effectiveType?: string; - saveData?: boolean; +type ThumbnailLoadState = { + id: string; + loaded: boolean; + failed: boolean; +}; + +type FullResolutionState = { + id: string; + requested: boolean; + loaded: boolean; + failed: boolean; +}; + +type PreparedSwipeSnapshot = { + imageId: string; + element: HTMLDivElement; }; type ViewportSize = { @@ -72,7 +105,6 @@ type GestureHandlers = { }; const DEFAULT_TRANSFORM: ViewerTransform = { scale: 1, x: 0, y: 0 }; -const originalPreloads = new Map(); function readViewportSize(): ViewportSize { return { @@ -106,15 +138,6 @@ function useViewportSize(): ViewportSize { 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)); } @@ -135,31 +158,40 @@ function rubberBand(distance: number, limit: number): number { 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"; +function mouseSideDirection(button: number): -1 | 1 | null { + if (button === 3) return -1; + if (button === 4) return 1; + return null; } -export function preloadOriginalImage(image: GalleryImage): void { - if (!shouldPreloadViewerImages()) return; - const url = originalUrl(image); - if (originalPreloads.has(url)) return; +function viewerMediaDimensions( + image: GalleryImage, + viewport: ViewportSize, +): { width: number; height: number } { + const compact = viewport.width <= 720; + const availableWidth = Math.max(1, viewport.width - (compact ? 12 : 144)); + const availableHeight = Math.max(1, viewport.height - (compact ? 128 : 104)); + const imageRatio = image.width / Math.max(1, image.height); + const width = Math.min(availableWidth, availableHeight * imageRatio); + return { width, height: width / imageRatio }; +} - 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); - } +function viewportRenderDimensions( + image: GalleryImage, + viewport: ViewportSize, +): { width: number; height: number } { + const media = viewerMediaDimensions(image, viewport); + const density = Math.min(3, Math.max(1, window.devicePixelRatio || 1)); + const requestedWidth = media.width * density; + const requestedHeight = media.height * density; + const scale = Math.min( + 1, + MOBILE_VIEWPORT_RENDER_EDGE / Math.max(1, requestedWidth, requestedHeight), + ); + return { + width: Math.max(1, Math.round(requestedWidth * scale)), + height: Math.max(1, Math.round(requestedHeight * scale)), + }; } export function ImageViewer({ @@ -181,10 +213,18 @@ 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 canPrevious = activeIndex > 0; const canNext = activeIndex < images.length - 1 || hasMore; const waitingForNext = loadingMore && activeIndex === images.length - 1; const dialogRef = useRef(null); + const detailsSectionRef = useRef(null); const closeButtonRef = useRef(null); const surfaceRef = useRef(null); const mediaRef = useRef(null); @@ -193,12 +233,21 @@ export function ImageViewer({ const gestureStartRef = useRef(null); const pinchStartRef = useRef(null); const gestureAxisRef = useRef<"x" | "y" | null>(null); + const gestureModeRef = useRef(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 scrollTopRef = useRef(0); + const scrollPageRef = useRef("image"); + const wheelZoomBlockedUntilRef = useRef(0); + const displayedOriginalsRef = useRef(new Set()); + const swipeCleanupTimerRef = useRef(0); + const swipeOutgoingRef = useRef(null); + const preparedSwipeSnapshotRef = useRef(null); const [dragging, setDragging] = useState(false); + const [scrollPage, setScrollPage] = useState("image"); const [fullscreen, setFullscreen] = useState(false); const [fullscreenAvailable, setFullscreenAvailable] = useState( () => typeof document !== "undefined" @@ -207,25 +256,85 @@ export function ImageViewer({ ); const [transform, setTransform] = useState(DEFAULT_TRANSFORM); const transformRef = useRef(DEFAULT_TRANSFORM); - const [nativeFallbackId, setNativeFallbackId] = useState(null); + const [thumbnailState, setThumbnailState] = useState({ + id: image.id, + 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 [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: false, failed: false }; - const fullSource = originalUrl(image, currentLoadState.attempt); - const useBitmapRenderer = typeof createImageBitmap === "function" - && nativeFallbackId !== image.id; + : { id: image.id, attempt: 0, loaded: fallbackOriginalReady, failed: false }; + const currentFullResolution = fullResolutionState.id === image.id + ? fullResolutionState + : { id: image.id, requested: false, loaded: false, failed: false }; + const thumbnailLoaded = thumbnailState.id === image.id + ? thumbnailState.loaded + : getViewerThumbnailStatus(image) === "ready"; + const thumbnailFailed = thumbnailState.id === image.id && thumbnailState.failed; + const fullSource = viewerOriginalUrl(image, currentLoadState.attempt); const commitTransform = (next: ViewerTransform) => { transformRef.current = next; setTransform(next); }; + const syncScrollPage = (top: number) => { + const viewer = dialogRef.current; + const detailsTop = detailsSectionRef.current?.offsetTop ?? viewer?.clientHeight ?? 0; + const nextPage: ViewerPage = top <= VIEWER_SCROLL_EPSILON + ? "image" + : detailsTop > 0 && top >= detailsTop - VIEWER_SCROLL_EPSILON + ? "details" + : "transition"; + if (scrollPageRef.current === nextPage) return; + scrollPageRef.current = nextPage; + setScrollPage(nextPage); + }; + + const forceViewerScroll = (top: number) => { + const viewer = dialogRef.current; + if (!viewer) return; + const previousBehavior = viewer.style.scrollBehavior; + viewer.style.scrollBehavior = "auto"; + viewer.scrollTop = top; + viewer.style.scrollBehavior = previousBehavior; + scrollTopRef.current = viewer.scrollTop; + syncScrollPage(viewer.scrollTop); + }; + const constrainTransform = (next: ViewerTransform): ViewerTransform => { const scale = clamp(next.scale, MIN_SCALE, MAX_SCALE); if (scale <= MIN_SCALE) return DEFAULT_TRANSFORM; @@ -250,6 +359,10 @@ export function ImageViewer({ commitTransform(DEFAULT_TRANSFORM); return; } + if ((dialogRef.current?.scrollTop ?? scrollTopRef.current) > VIEWER_SCROLL_EPSILON) { + return; + } + forceViewerScroll(0); const bounds = surface.getBoundingClientRect(); const center = { @@ -272,19 +385,207 @@ export function ImageViewer({ zoomAt(transformRef.current.scale + amount); }; + const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + const scrollToDetails = (focus = false) => { + commitTransform(DEFAULT_TRANSFORM); + wheelZoomBlockedUntilRef.current = performance.now() + WHEEL_HANDOFF_DELAY_MS; + const viewer = dialogRef.current; + const details = detailsSectionRef.current; + if (!viewer || !details) return; + viewer.scrollTo({ + top: details.offsetTop, + behavior: reducedMotion() ? "auto" : "smooth", + }); + if (focus) { + window.requestAnimationFrame(() => details.focus({ preventScroll: true })); + } + }; + + const scrollToImage = (focus = false) => { + wheelZoomBlockedUntilRef.current = performance.now() + WHEEL_HANDOFF_DELAY_MS; + dialogRef.current?.scrollTo({ + top: 0, + behavior: reducedMotion() ? "auto" : "smooth", + }); + if (focus) { + window.requestAnimationFrame(() => closeButtonRef.current?.focus({ preventScroll: true })); + } + }; + + const parkViewerControlFocus = () => { + const viewer = dialogRef.current; + const active = document.activeElement; + if ( + viewer + && active instanceof HTMLElement + && active !== viewer + && viewer.contains(active) + ) { + viewer.focus({ preventScroll: true }); + } + }; + + const clearSwipeMotion = () => { + window.clearTimeout(swipeCleanupTimerRef.current); + swipeCleanupTimerRef.current = 0; + dialogRef.current?.removeAttribute("data-swipe-direction"); + const outgoing = swipeOutgoingRef.current; + swipeOutgoingRef.current = null; + outgoing?.getAnimations().forEach((animation) => animation.cancel()); + outgoing?.remove(); + }; + + const prepareSwipeSnapshot = (): PreparedSwipeSnapshot | null => { + const media = mediaRef.current; + if (!media) return null; + const snapshot = media.cloneNode(true) as HTMLDivElement; + snapshot.classList.add("viewer-swipe-outgoing"); + snapshot.setAttribute("aria-hidden", "true"); + snapshot.querySelectorAll("[id]").forEach((element) => { + element.removeAttribute("id"); + }); + const thumbnail = snapshot.querySelector(".viewer-thumbnail"); + snapshot.querySelectorAll(".viewer-original").forEach((original) => original.remove()); + if (thumbnail) { + thumbnail.style.opacity = "1"; + thumbnail.style.filter = "none"; + } + return { + imageId: image.id, + element: snapshot, + }; + }; + + const takePreparedSwipeSnapshot = () => { + const media = mediaRef.current; + const prepared = preparedSwipeSnapshotRef.current; + if ( + !media + || !prepared + || prepared.imageId !== image.id + ) return null; + preparedSwipeSnapshotRef.current = null; + const bounds = media.getBoundingClientRect(); + const snapshot = prepared.element; + Object.assign(snapshot.style, { + position: "fixed", + left: `${bounds.left}px`, + top: `${bounds.top}px`, + width: `${bounds.width}px`, + height: `${bounds.height}px`, + transform: "translate3d(0, 0, 0)", + }); + document.body.append(snapshot); + return snapshot; + }; + + const navigateWithSwipeMotion = (direction: -1 | 1) => { + if (!compactViewport || reducedMotion()) { + commitTransform(DEFAULT_TRANSFORM); + onNavigate(direction); + return; + } + + clearSwipeMotion(); + const releaseStartedAt = performance.now(); + const outgoing = takePreparedSwipeSnapshot(); + const viewer = dialogRef.current; + if (viewer) viewer.dataset.swipeDirection = direction > 0 ? "next" : "previous"; + commitTransform(DEFAULT_TRANSFORM); + onNavigate(direction); + if (viewer) { + viewer.dataset.swipeSnapshot = outgoing ? "prepared" : "skipped"; + viewer.dataset.swipeReleaseCostMs = (performance.now() - releaseStartedAt).toFixed(2); + } + + if (outgoing) { + swipeOutgoingRef.current = outgoing; + const travel = Math.max(128, viewport.width * 0.38) * -direction; + const animation = outgoing.animate( + [ + { opacity: 1, transform: "translate3d(0, 0, 0) scale(1)" }, + { opacity: 0, transform: `translate3d(${travel}px, 0, 0) scale(.985)` }, + ], + { + duration: MOBILE_SWIPE_MOTION_MS, + easing: "cubic-bezier(.18, .82, .24, 1)", + fill: "forwards", + }, + ); + void animation.finished.catch(() => undefined).then(() => { + if (swipeOutgoingRef.current !== outgoing) return; + swipeOutgoingRef.current = null; + outgoing.remove(); + }); + } + + swipeCleanupTimerRef.current = window.setTimeout(() => { + dialogRef.current?.removeAttribute("data-swipe-direction"); + const staleOutgoing = swipeOutgoingRef.current; + swipeOutgoingRef.current = null; + staleOutgoing?.remove(); + swipeCleanupTimerRef.current = 0; + }, MOBILE_SWIPE_CLEANUP_MS); + }; + useLayoutEffect(() => { window.clearTimeout(retryTimerRef.current); pointersRef.current.clear(); gestureStartRef.current = null; pinchStartRef.current = null; gestureAxisRef.current = null; + gestureModeRef.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]); + wheelZoomBlockedUntilRef.current = 0; + forceViewerScroll(0); + setThumbnailState({ + id: image.id, + 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, + }); + setFullResolutionState({ + id: image.id, + requested: false, + loaded: false, + failed: false, + }); + }, [ + image.id, + useSafeCanvasRenderer, + useViewportBitmapRenderer, + viewportRender.height, + viewportRender.width, + ]); + + useLayoutEffect(() => { + if (transformRef.current.scale > MIN_SCALE) { + forceViewerScroll(0); + commitTransform(constrainTransform(transformRef.current)); + return; + } + if (scrollPageRef.current === "details") { + forceViewerScroll(detailsSectionRef.current?.offsetTop ?? viewport.height); + } + }, [viewport.width, viewport.height]); useEffect(() => { const previouslyFocused = document.activeElement instanceof HTMLElement @@ -369,12 +670,14 @@ export function ImageViewer({ case "ArrowLeft": if (canPrevious) { event.preventDefault(); + parkViewerControlFocus(); onNavigate(-1); } break; case "ArrowRight": if (canNext) { event.preventDefault(); + parkViewerControlFocus(); onNavigate(1); } break; @@ -391,6 +694,26 @@ export function ImageViewer({ event.preventDefault(); commitTransform(DEFAULT_TRANSFORM); break; + case "ArrowDown": + case "PageDown": + if ( + transformRef.current.scale <= MIN_SCALE && + (dialogRef.current?.scrollTop ?? 0) < (detailsSectionRef.current?.offsetTop ?? Infinity) + ) { + event.preventDefault(); + scrollToDetails(true); + } + break; + case "ArrowUp": + case "PageUp": + if ( + transformRef.current.scale <= MIN_SCALE && + (dialogRef.current?.scrollTop ?? 0) > 1 + ) { + event.preventDefault(); + scrollToImage(true); + } + break; } }; window.addEventListener("keydown", handleKeyDown); @@ -398,21 +721,168 @@ export function ImageViewer({ }, [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); + const pressedButtons = new Set(); + const suppressSideButton = (event: MouseEvent | PointerEvent) => { + if (mouseSideDirection(event.button) === null) return false; + event.preventDefault(); + event.stopPropagation(); + return true; + }; + const navigateFromSideButton = (event: MouseEvent | PointerEvent) => { + const direction = mouseSideDirection(event.button); + if (direction === null || !suppressSideButton(event)) return; + if (pressedButtons.has(event.button)) return; + pressedButtons.add(event.button); + if (direction < 0 && canPrevious) onNavigate(-1); + if (direction > 0 && canNext) onNavigate(1); + }; + const releaseSideButton = (event: MouseEvent | PointerEvent) => { + if (!suppressSideButton(event)) return; + pressedButtons.delete(event.button); + }; + const resetSideButtons = () => pressedButtons.clear(); + const handlePointerDown = (event: PointerEvent) => { + if (event.pointerType === "mouse") navigateFromSideButton(event); + }; + const handlePointerCancel = (event: PointerEvent) => { + if (mouseSideDirection(event.button) !== null) suppressSideButton(event); + resetSideButtons(); + }; - 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]); + window.addEventListener("pointerdown", handlePointerDown, true); + window.addEventListener("pointerup", releaseSideButton, true); + window.addEventListener("pointercancel", handlePointerCancel, true); + window.addEventListener("mousedown", navigateFromSideButton, true); + window.addEventListener("mouseup", releaseSideButton, true); + window.addEventListener("auxclick", suppressSideButton, true); + window.addEventListener("blur", resetSideButtons); + return () => { + window.removeEventListener("pointerdown", handlePointerDown, true); + window.removeEventListener("pointerup", releaseSideButton, true); + window.removeEventListener("pointercancel", handlePointerCancel, true); + window.removeEventListener("mousedown", navigateFromSideButton, true); + window.removeEventListener("mouseup", releaseSideButton, true); + window.removeEventListener("auxclick", suppressSideButton, true); + window.removeEventListener("blur", resetSideButtons); + }; + }, [canNext, canPrevious, onNavigate]); - useEffect(() => () => window.clearTimeout(retryTimerRef.current), []); + 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, + 0, + offset === 0 ? "high" : "low", + ); + } + }, [ + activeIndex, + images, + useViewportBitmapRenderer, + viewport.height, + viewport.width, + ]); + + useEffect(() => { + if ( + !useViewportBitmapRenderer + || !currentLoadState.loaded + || currentFullResolution.requested + || dragging + ) 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); + }; + }, [ + currentFullResolution.requested, + currentLoadState.loaded, + dragging, + image.id, + transform.scale, + useViewportBitmapRenderer, + ]); + + useEffect(() => { + if (!compactViewport || reducedMotion()) { + preparedSwipeSnapshotRef.current = null; + return; + } + let disposed = false; + let prepared: PreparedSwipeSnapshot | null = null; + let idleHandle = 0; + let fallbackTimer = 0; + const prepare = () => { + if (disposed) return; + prepared = prepareSwipeSnapshot(); + if (prepared) preparedSwipeSnapshotRef.current = prepared; + }; + const idleScheduler = window as unknown as { + requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number; + cancelIdleCallback?: (handle: number) => void; + }; + if (idleScheduler.requestIdleCallback) { + idleHandle = idleScheduler.requestIdleCallback(prepare, { timeout: 180 }); + } else { + fallbackTimer = globalThis.setTimeout(prepare, 32); + } + return () => { + disposed = true; + if (idleHandle) idleScheduler.cancelIdleCallback?.(idleHandle); + globalThis.clearTimeout(fallbackTimer); + if (preparedSwipeSnapshotRef.current === prepared) { + preparedSwipeSnapshotRef.current = null; + } + }; + }, [ + compactViewport, + image.id, + thumbnailLoaded, + viewportRender.height, + viewportRender.width, + ]); + + useEffect(() => () => { + window.clearTimeout(retryTimerRef.current); + window.clearTimeout(swipeCleanupTimerRef.current); + swipeOutgoingRef.current?.remove(); + preparedSwipeSnapshotRef.current = null; + }, []); const handleOriginalError = () => { if (currentLoadState.attempt === 0) { @@ -440,95 +910,95 @@ export function ImageViewer({ }); }; - useEffect(() => { - if (!useBitmapRenderer) return; - const controller = new AbortController(); - let bitmap: ImageBitmap | null = null; + useLayoutEffect(() => { + if (!useCanvasRenderer) return; let disposed = false; + let frame = 0; + const attempt = currentLoadState.attempt; + const viewportAsset = useViewportBitmapRenderer + ? getViewerViewportRenderAsset( + image, + viewportRender.width, + viewportRender.height, + attempt, + "high", + ) + : null; + const originalAsset = useSafeCanvasRenderer + ? getViewerOriginalAsset(image, attempt, "high") + : null; - const renderOriginal = async () => { + const renderOriginal = () => { + const status = viewportAsset?.status ?? originalAsset?.status; + if (disposed || status === "loading") return; + if (status === "failed") { + handleOriginalError(); + return; + } 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 renderScale = useViewportBitmapRenderer + ? 1 + : Math.min(1, MAX_RENDER_EDGE / Math.max(1, image.width, image.height)); + const renderWidth = viewportAsset?.width + ?? Math.max(1, Math.round(image.width * renderScale)); + const renderHeight = viewportAsset?.height + ?? 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) throw new Error("canvas renderer is unavailable"); + 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(bitmap, 0, 0, renderWidth, renderHeight); - bitmap.close(); - bitmap = null; + context.drawImage(source, 0, 0, renderWidth, renderHeight); window.clearTimeout(retryTimerRef.current); setLoadState({ id: image.id, - attempt: currentLoadState.attempt, + attempt, loaded: true, failed: false, }); } catch (error) { - if (disposed || controller.signal.aborted) return; + if (disposed) return; console.warn("Pixhelf could not render the original image", error); handleOriginalError(); } }; - void renderOriginal(); + const scheduleRender = () => { + if (disposed || frame) return; + frame = window.requestAnimationFrame(() => { + frame = 0; + renderOriginal(); + }); + }; + + if (viewportAsset?.status === "ready") renderOriginal(); + else scheduleRender(); + const pending = viewportAsset ?? originalAsset; + if (pending?.status === "loading") void pending.promise.then(scheduleRender); return () => { disposed = true; - controller.abort(); - bitmap?.close(); + if (frame) window.cancelAnimationFrame(frame); }; - }, [fullSource, image.id, image.width, image.height, useBitmapRenderer]); + }, [ + fullSource, + image.id, + image.width, + image.height, + useCanvasRenderer, + useSafeCanvasRenderer, + useViewportBitmapRenderer, + viewportRender.height, + viewportRender.width, + ]); const beginContact = (id: number, point: PointerPoint, pointerType: string) => { if (pointerType !== "mouse") lastTouchAtRef.current = performance.now(); + if (pointerType !== "mouse" && pointersRef.current.size === 0) clearSwipeMotion(); pointersRef.current.set(id, point); setDragging(true); @@ -536,19 +1006,26 @@ export function ImageViewer({ gestureStartRef.current = { point, transform: transformRef.current, + scrollTop: dialogRef.current?.scrollTop ?? 0, startedAt: performance.now(), pointerType, }; gestureAxisRef.current = null; + gestureModeRef.current = null; } else if (pointersRef.current.size === 2) { const [first, second] = Array.from(pointersRef.current.values()); pinchedRef.current = true; + gestureModeRef.current = "pinch"; lastTapRef.current = null; - pinchStartRef.current = { - distance: Math.max(1, pointDistance(first, second)), - midpoint: pointMidpoint(first, second), - transform: transformRef.current, - }; + if ((dialogRef.current?.scrollTop ?? scrollTopRef.current) <= VIEWER_SCROLL_EPSILON) { + pinchStartRef.current = { + distance: Math.max(1, pointDistance(first, second)), + midpoint: pointMidpoint(first, second), + transform: transformRef.current, + }; + } else { + pinchStartRef.current = null; + } } }; @@ -583,6 +1060,7 @@ export function ImageViewer({ })); return; } + if (pointersRef.current.size >= 2 || pinchedRef.current) return; const start = gestureStartRef.current; if (!start || pointersRef.current.size !== 1) return; @@ -590,9 +1068,19 @@ export function ImageViewer({ 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"; + gestureModeRef.current = start.transform.scale > MIN_SCALE + ? "pan" + : start.scrollTop > VIEWER_SCROLL_EPSILON + ? "page" + : gestureAxisRef.current === "x" + ? "navigate" + : deltaY < 0 + ? "page" + : "dismiss"; } - if (start.transform.scale > MIN_SCALE) { + if (gestureModeRef.current === "pan") { + forceViewerScroll(0); commitTransform(constrainTransform({ ...start.transform, x: start.transform.x + deltaX, @@ -602,17 +1090,31 @@ export function ImageViewer({ } const surface = surfaceRef.current; - if (gestureAxisRef.current === "x") { + if (gestureModeRef.current === "navigate") { + const navigationAvailable = deltaX < 0 ? canNext : deltaX > 0 && canPrevious; + const maximumTravel = Math.max(1, (surface?.clientWidth ?? 360) * 0.94); commitTransform({ scale: 1, - x: rubberBand(deltaX, Math.max(90, (surface?.clientWidth ?? 360) * 0.32)), + x: navigationAvailable + ? clamp(deltaX * 0.92, -maximumTravel, maximumTravel) + : rubberBand(deltaX, Math.max(72, maximumTravel * 0.22)) * 0.38, y: 0, }); - } else if (gestureAxisRef.current === "y") { + } else if (gestureModeRef.current === "page") { + const viewer = dialogRef.current; + if (viewer) { + const detailsTop = detailsSectionRef.current?.offsetTop ?? viewer.clientHeight; + 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); + return; + } + } else if (gestureModeRef.current === "dismiss") { commitTransform({ scale: 1, x: 0, - y: rubberBand(deltaY, Math.max(80, (surface?.clientHeight ?? 640) * 0.2)), + y: rubberBand(Math.max(0, deltaY), Math.max(80, (surface?.clientHeight ?? 640) * 0.2)), }); } }; @@ -650,6 +1152,7 @@ export function ImageViewer({ gestureStartRef.current = { point: remaining, transform: transformRef.current, + scrollTop: dialogRef.current?.scrollTop ?? 0, startedAt: performance.now(), pointerType, }; @@ -659,11 +1162,22 @@ export function ImageViewer({ setDragging(false); const start = gestureStartRef.current; + const gestureMode = gestureModeRef.current; gestureStartRef.current = null; pinchStartRef.current = null; + gestureAxisRef.current = null; + gestureModeRef.current = null; const wasPinched = pinchedRef.current; pinchedRef.current = false; if (wasPinched) { + const viewer = dialogRef.current; + const detailsTop = detailsSectionRef.current?.offsetTop ?? viewer?.clientHeight ?? 0; + const currentScroll = viewer?.scrollTop ?? 0; + if (currentScroll > VIEWER_SCROLL_EPSILON && detailsTop > 0) { + if (currentScroll >= detailsTop * 0.18) scrollToDetails(); + else scrollToImage(); + return; + } commitTransform(constrainTransform(transformRef.current)); return; } @@ -688,25 +1202,35 @@ export function ImageViewer({ 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; + const detailsFlick = deltaY < -38 && -deltaY / elapsed > 0.52; if ( - gestureAxisRef.current === "x" && + gestureMode === "navigate" && Math.abs(deltaX) > Math.abs(deltaY) && (Math.abs(deltaX) >= horizontalThreshold || horizontalFlick) ) { if (deltaX < 0 && canNext) { - commitTransform(DEFAULT_TRANSFORM); - onNavigate(1); + navigateWithSwipeMotion(1); return; } if (deltaX > 0 && canPrevious) { - commitTransform(DEFAULT_TRANSFORM); - onNavigate(-1); + navigateWithSwipeMotion(-1); return; } } + if (gestureMode === "page") { + const viewer = dialogRef.current; + const detailsTop = detailsSectionRef.current?.offsetTop ?? viewer?.clientHeight ?? 640; + const currentScroll = viewer?.scrollTop ?? 0; + if (detailsFlick || currentScroll >= detailsTop * 0.18) { + scrollToDetails(); + } else { + scrollToImage(); + } + return; + } if ( - gestureAxisRef.current === "y" && + gestureMode === "dismiss" && deltaY > Math.abs(deltaX) && (deltaY >= verticalThreshold || verticalFlick) ) { @@ -725,8 +1249,19 @@ export function ImageViewer({ gestureStartRef.current = null; pinchStartRef.current = null; gestureAxisRef.current = null; + gestureModeRef.current = null; pinchedRef.current = false; setDragging(false); + const viewer = dialogRef.current; + const detailsTop = detailsSectionRef.current?.offsetTop ?? viewer?.clientHeight ?? 0; + if ((viewer?.scrollTop ?? 0) > 1 && detailsTop > 0) { + if ((viewer?.scrollTop ?? 0) >= detailsTop * 0.18) { + scrollToDetails(); + } else { + scrollToImage(); + } + return; + } commitTransform(constrainTransform(transformRef.current)); }; @@ -824,14 +1359,52 @@ export function ImageViewer({ }; }, []); + const handleViewerScroll = (event: JSX.TargetedEvent) => { + const viewer = event.currentTarget; + const previousTop = scrollTopRef.current; + const nextTop = viewer.scrollTop; + if (transformRef.current.scale > MIN_SCALE && nextTop > VIEWER_SCROLL_EPSILON) { + forceViewerScroll(0); + return; + } + scrollTopRef.current = nextTop; + syncScrollPage(nextTop); + if (nextTop > VIEWER_SCROLL_EPSILON || previousTop > VIEWER_SCROLL_EPSILON) { + wheelZoomBlockedUntilRef.current = performance.now() + WHEEL_HANDOFF_DELAY_MS; + } + }; + const handleWheel = (event: JSX.TargetedWheelEvent) => { + const now = performance.now(); + const scrollTop = dialogRef.current?.scrollTop ?? scrollTopRef.current; + if (scrollTop > VIEWER_SCROLL_EPSILON) { + if (event.ctrlKey) event.preventDefault(); + return; + } + const currentScale = transformRef.current.scale; + if (currentScale <= MIN_SCALE && !event.ctrlKey && event.deltaY > 0) { + if (now < wheelZoomBlockedUntilRef.current) event.preventDefault(); + return; + } + if (now < wheelZoomBlockedUntilRef.current) { + event.preventDefault(); + return; + } event.preventDefault(); - const factor = Math.exp(-event.deltaY * 0.002); - zoomAt(transformRef.current.scale * factor, { x: event.clientX, y: event.clientY }); + const deltaMultiplier = event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? 16 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? surfaceRef.current?.clientHeight ?? viewport.height + : 1; + const normalizedDelta = clamp(event.deltaY * deltaMultiplier, -160, 160); + const nextScale = currentScale * Math.exp(-normalizedDelta * 0.002); + if (currentScale > MIN_SCALE && nextScale <= MIN_SCALE) { + wheelZoomBlockedUntilRef.current = now + WHEEL_HANDOFF_DELAY_MS; + } + zoomAt(nextScale, { 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 @@ -839,12 +1412,17 @@ export function ImageViewer({ : 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 mediaWidth = mediaDimensions.width; + const mediaHeight = mediaDimensions.height; + const fileExtension = image.name.includes(".") + ? image.name.split(".").pop()?.toLocaleUpperCase() ?? "图片" + : "图片"; + const orientation = image.width === image.height + ? "方形" + : image.width > image.height + ? "横向" + : "竖向"; + const megapixels = image.width * image.height / 1_000_000; const mediaStyle = { width: `${mediaWidth}px`, height: `${mediaHeight}px`, @@ -857,29 +1435,50 @@ export function ImageViewer({ className="image-viewer" role="dialog" aria-modal="true" - aria-labelledby="image-viewer-title" + aria-label={`图片查看器:${image.name}`} + data-image-id={image.id} + data-image-name={image.name} data-full-loaded={currentLoadState.loaded} data-full-failed={currentLoadState.failed} + data-thumbnail-loaded={thumbnailLoaded} + data-thumbnail-failed={thumbnailFailed} + data-display-source={currentLoadState.loaded + ? !useViewportBitmapRenderer + || currentFullResolution.loaded && transform.scale > MIN_SCALE + ? "original" + : "viewport-bitmap" + : thumbnailLoaded + ? "thumbnail" + : "placeholder"} + 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-dragging={dragging} - data-renderer={useBitmapRenderer ? "bitmap" : "native"} + data-page={scrollPage} + data-renderer={useViewportBitmapRenderer + ? "viewport-bitmap" + : useSafeCanvasRenderer + ? "safe-canvas" + : "native"} + data-control-system="unified" + data-scroll-mode="continuous" + data-mouse-side-navigation="true" + data-mobile-swipe-motion="interruptible" + tabIndex={-1} + onScroll={handleViewerScroll} style={viewerStyle} > -