Release v0.1.2
This commit is contained in:
1 parent
f6113593d0
commit
d2d7c23cbd
21 files changed
+2558
-3298
No files matched your search
Generated
+1
-1
@@ -581,7 +581,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pixhelf"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pixhelf"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
@@ -26,4 +26,3 @@ tempfile = "3.27.0"
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = "symbols"
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
./target/release/pixhelf
|
||||
```
|
||||
|
||||
首次启动会先生成首批 60 张、最长边 720px 的 WebP 缩略图,然后开放 HTTP 服务。其余缩略图由后台任务持续处理;打开图片时,查看器会按需生成独立的最长边 2560px WebP 预览。新增、修改或删除图片会在定期扫描后自动同步。
|
||||
HTTP 服务会立即开放,并优先生成首批 60 张、最长边 720px 的 WebP 缩略图。其余缩略图由后台任务持续处理;新增、修改或删除图片会在定期扫描后自动同步。图库为空时也可正常启动并等待后续图片。
|
||||
|
||||
缩略图和查看器预览缓存在 `./.pixhelf-cache/thumbnails` 下的版本化目录,原图不会被修改。
|
||||
缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的版本化目录,原图不会被修改。缓存目录必须位于图库目录之外。
|
||||
|
||||
## 构建
|
||||
|
||||
@@ -31,7 +31,7 @@ cargo build --release
|
||||
|
||||
```text
|
||||
--gallery-dir PATH 图库目录,默认 ./pic
|
||||
--cache-dir PATH 缩略图和查看器预览缓存,默认 ./.pixhelf-cache/thumbnails
|
||||
--cache-dir PATH 缩略图缓存,默认 ./.pixhelf-cache/thumbnails
|
||||
--listen HOST:PORT 监听地址,默认 0.0.0.0:3002
|
||||
--initial-batch N 启动前生成数量,默认 60
|
||||
--workers N 后台压缩并发数,默认按 CPU 自动选择 2-4
|
||||
|
||||
@@ -14,6 +14,7 @@ fn main() {
|
||||
] {
|
||||
println!("cargo:rerun-if-changed={path}");
|
||||
}
|
||||
println!("cargo:rerun-if-env-changed=PIXHELF_SKIP_FRONTEND_BUILD");
|
||||
|
||||
if env::var_os("PIXHELF_SKIP_FRONTEND_BUILD").is_some() {
|
||||
return;
|
||||
|
||||
+5
-1
@@ -2,9 +2,13 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
|
||||
/>
|
||||
<meta name="theme-color" content="#f4f5f4" />
|
||||
<meta name="description" content="Pixhelf 私人图片画廊" />
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23287a72'/%3E%3Cpath d='M8 8h7v7H8zm9 0h7v7h-7zM8 17h7v7H8zm9 0h7v7h-7z' fill='white'/%3E%3C/svg%3E" />
|
||||
<title>Pixhelf</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pixhelf-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pixhelf-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.539.0",
|
||||
"react": "^19.1.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pixhelf-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
+491
-205
@@ -13,51 +13,6 @@ const browser = await chromium.launch({
|
||||
|
||||
const results = [];
|
||||
|
||||
async function dispatchTouch(cdp, type, points) {
|
||||
await cdp.send("Input.dispatchTouchEvent", {
|
||||
type,
|
||||
touchPoints: points.map((point) => ({
|
||||
id: point.id,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
radiusX: 5,
|
||||
radiusY: 5,
|
||||
force: 1,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async function touchTap(cdp, point, pointerId = 1) {
|
||||
await dispatchTouch(cdp, "touchStart", [{ ...point, id: pointerId }]);
|
||||
await dispatchTouch(cdp, "touchEnd", []);
|
||||
}
|
||||
|
||||
async function touchDrag(cdp, from, to, steps = 10, pointerId = 1) {
|
||||
await dispatchTouch(cdp, "touchStart", [{ ...from, id: pointerId }]);
|
||||
for (let step = 1; step <= steps; step += 1) {
|
||||
await dispatchTouch(cdp, "touchMove", [{
|
||||
id: pointerId,
|
||||
x: from.x + (to.x - from.x) * (step / steps),
|
||||
y: from.y + (to.y - from.y) * (step / steps),
|
||||
}]);
|
||||
}
|
||||
await dispatchTouch(cdp, "touchEnd", []);
|
||||
}
|
||||
|
||||
async function touchPinch(cdp, center, fromDistance, toDistance, steps = 10) {
|
||||
const firstStart = { id: 1, x: center.x - fromDistance / 2, y: center.y };
|
||||
const secondStart = { id: 2, x: center.x + fromDistance / 2, y: center.y };
|
||||
await dispatchTouch(cdp, "touchStart", [firstStart, secondStart]);
|
||||
for (let step = 1; step <= steps; step += 1) {
|
||||
const distance = fromDistance + (toDistance - fromDistance) * (step / steps);
|
||||
await dispatchTouch(cdp, "touchMove", [
|
||||
{ id: 1, x: center.x - distance / 2, y: center.y },
|
||||
{ id: 2, x: center.x + distance / 2, y: center.y },
|
||||
]);
|
||||
}
|
||||
await dispatchTouch(cdp, "touchEnd", []);
|
||||
}
|
||||
|
||||
try {
|
||||
for (const target of [
|
||||
{ name: "desktop", width: 1440, height: 1000 },
|
||||
@@ -81,198 +36,529 @@ try {
|
||||
const rect = image.getBoundingClientRect();
|
||||
return rect.bottom > 0 && rect.top < innerHeight;
|
||||
});
|
||||
return visible.length >= 8 && visible.every((image) => image.complete && image.naturalWidth > 0);
|
||||
return visible.length >= 8 &&
|
||||
visible.every((image) => image.complete && image.naturalWidth > 0);
|
||||
}, undefined, { timeout: 60_000 });
|
||||
await page.waitForTimeout(250);
|
||||
await page.screenshot({ path: `/tmp/pixhelf-${target.name}.png` });
|
||||
|
||||
const layout = await page.evaluate(() => ({
|
||||
viewport: [innerWidth, innerHeight],
|
||||
bodyWidth: document.documentElement.scrollWidth,
|
||||
cards: document.querySelectorAll("[data-image-id]").length,
|
||||
masonryColumns: document.querySelectorAll(".masonry-column").length,
|
||||
loadedCards: document.querySelectorAll("[data-image-id] img.loaded").length,
|
||||
brokenVisibleImages: [...document.images].filter((image) => {
|
||||
const rect = image.getBoundingClientRect();
|
||||
return rect.bottom > 0 && rect.top < innerHeight && image.naturalWidth === 0;
|
||||
}).length,
|
||||
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
|
||||
}));
|
||||
const layout = await page.evaluate(() => {
|
||||
const path = document.querySelector(".desktop-sidebar .sidebar-gallery-path");
|
||||
const count = document.querySelector(".desktop-sidebar .sidebar-gallery-count");
|
||||
const search = document.querySelector(".topbar-search");
|
||||
const topbar = document.querySelector(".topbar");
|
||||
const sidebarStatus = document.querySelector(".desktop-sidebar .sidebar-status");
|
||||
const sidebarMeta = document.querySelector(".desktop-sidebar .sidebar-gallery-meta");
|
||||
const pathRect = path?.getBoundingClientRect();
|
||||
const countRect = count?.getBoundingClientRect();
|
||||
const searchRect = search?.getBoundingClientRect();
|
||||
const exploreRect = document.querySelector(".explore-toggle")?.getBoundingClientRect();
|
||||
const topbarRect = topbar?.getBoundingClientRect();
|
||||
const statusRect = sidebarStatus?.getBoundingClientRect();
|
||||
const metaRect = sidebarMeta?.getBoundingClientRect();
|
||||
const galleryToggle = document.querySelector(".gallery-sidebar-toggle");
|
||||
return {
|
||||
viewport: [innerWidth, innerHeight],
|
||||
bodyWidth: document.documentElement.scrollWidth,
|
||||
cards: document.querySelectorAll("[data-image-id]").length,
|
||||
cardTags: [...document.querySelectorAll("[data-image-id]")]
|
||||
.map((element) => element.tagName),
|
||||
masonryColumns: document.querySelectorAll(".masonry-column").length,
|
||||
loadedCards: document.querySelectorAll("[data-image-id] img.loaded").length,
|
||||
brokenVisibleImages: [...document.images].filter((image) => {
|
||||
const rect = image.getBoundingClientRect();
|
||||
return rect.bottom > 0 && rect.top < innerHeight && image.naturalWidth === 0;
|
||||
}).length,
|
||||
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
|
||||
dialogs: document.querySelectorAll('[role="dialog"]').length,
|
||||
topbarPosition: topbar ? getComputedStyle(topbar).position : "",
|
||||
topbarTop: topbarRect?.top ?? -1,
|
||||
topbarSearches: document.querySelectorAll(
|
||||
".topbar-actions > .topbar-search",
|
||||
).length,
|
||||
brandHomeLinks: document.querySelectorAll('a.brand[href="/"]').length,
|
||||
gallerySidebarToggles: document.querySelectorAll(".gallery-sidebar-toggle").length,
|
||||
galleryToggleOpacity: galleryToggle
|
||||
? Number.parseFloat(getComputedStyle(galleryToggle).opacity)
|
||||
: -1,
|
||||
topbarSidebarToggles: document.querySelectorAll(
|
||||
".topbar .sidebar-toggle-button",
|
||||
).length,
|
||||
contentSearches: document.querySelectorAll(".content .search-field").length,
|
||||
topbarSearchRightGap: searchRect ? innerWidth - searchRect.right : -1,
|
||||
exploreButtons: document.querySelectorAll(".topbar-actions > .explore-toggle").length,
|
||||
exploreSearchGap: exploreRect && searchRect ? searchRect.left - exploreRect.right : -1,
|
||||
sidebarStatuses: document.querySelectorAll(
|
||||
".desktop-sidebar > .sidebar-status",
|
||||
).length,
|
||||
sidebarGalleryMetas: document.querySelectorAll(".sidebar-gallery-meta").length,
|
||||
contentInfoRows: document.querySelectorAll(
|
||||
".content-heading, .content .sidebar-gallery-meta",
|
||||
).length,
|
||||
sortControls: document.querySelectorAll(".sort-control").length,
|
||||
cardNameDisplay: getComputedStyle(document.querySelector(".image-name")).display,
|
||||
cardNameOpacity: Number.parseFloat(
|
||||
getComputedStyle(document.querySelector(".image-name")).opacity,
|
||||
),
|
||||
cardNameWhiteSpace: getComputedStyle(
|
||||
document.querySelector(".image-name"),
|
||||
).whiteSpace,
|
||||
sidebarMetaBottomGap: metaRect ? innerHeight - metaRect.bottom : -1,
|
||||
sidebarMetaStatusGap: statusRect && metaRect ? metaRect.top - statusRect.bottom : -1,
|
||||
galleryPath: path?.textContent ?? "",
|
||||
galleryCount: count?.textContent ?? "",
|
||||
galleryMetaRowDelta: pathRect && countRect
|
||||
? Math.abs(pathRect.top - countRect.top)
|
||||
: -1,
|
||||
};
|
||||
});
|
||||
|
||||
await page.locator("[data-image-id]").first().click();
|
||||
await page.waitForSelector(".image-viewer");
|
||||
await page.waitForFunction(() => {
|
||||
const image = document.querySelector(".viewer-image-original");
|
||||
return image instanceof HTMLImageElement &&
|
||||
image.complete &&
|
||||
image.naturalWidth > 0 &&
|
||||
image.classList.contains("is-ready") &&
|
||||
Number(getComputedStyle(image).opacity) > 0.98;
|
||||
}, undefined, { timeout: 60_000 });
|
||||
await page.waitForTimeout(250);
|
||||
await page.screenshot({ path: `/tmp/pixhelf-viewer-${target.name}.png` });
|
||||
await page.waitForTimeout(200);
|
||||
const dialogsAfterImageClick = await page.locator('[role="dialog"]').count();
|
||||
|
||||
const viewerLayout = await page.evaluate(() => {
|
||||
const viewer = document.querySelector(".image-viewer");
|
||||
const frame = document.querySelector(".viewer-image-frame");
|
||||
const image = document.querySelector(".viewer-image-original");
|
||||
const transform = document.querySelector(".viewer-transform");
|
||||
if (!viewer || !frame || !(image instanceof HTMLImageElement) || !transform) {
|
||||
return { visible: false };
|
||||
}
|
||||
const rect = frame.getBoundingClientRect();
|
||||
const imageStyle = getComputedStyle(image);
|
||||
const searchToggleBefore = await page.locator(".search-toggle").boundingBox();
|
||||
await page.locator(".search-toggle").click();
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector(".search-toggle")?.getAttribute("aria-expanded") === "true"
|
||||
);
|
||||
await page.waitForTimeout(220);
|
||||
const searchToggleOpen = await page.locator(".search-toggle").boundingBox();
|
||||
const searchPanelOpen = await page.locator("#gallery-search-field").boundingBox();
|
||||
const searchDisclosure = await page.evaluate(() => {
|
||||
const leading = document.querySelector(".topbar-leading")?.getBoundingClientRect();
|
||||
const panel = document.querySelector("#gallery-search-field")?.getBoundingClientRect();
|
||||
const topbar = document.querySelector(".topbar")?.getBoundingClientRect();
|
||||
return {
|
||||
visible: true,
|
||||
frame: [rect.left, rect.top, rect.right, rect.bottom],
|
||||
naturalSize: [image.naturalWidth, image.naturalHeight],
|
||||
imageOpacity: imageStyle.opacity,
|
||||
objectFit: imageStyle.objectFit,
|
||||
noLegacyLightbox: document.querySelectorAll(".yarl__portal, .yarl__thumbnails_container").length === 0,
|
||||
controlsVisible: viewer.getAttribute("data-controls-visible"),
|
||||
transform: getComputedStyle(transform).transform,
|
||||
expanded: document.querySelector(".search-toggle")?.getAttribute("aria-expanded"),
|
||||
focused: document.activeElement?.matches("#gallery-search-field input") ?? false,
|
||||
panelRole: document.querySelector("#gallery-search-field")?.getAttribute("role"),
|
||||
panelInert: document.querySelector("#gallery-search-field")?.inert ?? true,
|
||||
leadingGap: leading && panel ? panel.left - leading.right : -1,
|
||||
panelTop: panel?.top ?? -1,
|
||||
topbarTop: topbar?.top ?? -1,
|
||||
topbarBottom: topbar?.bottom ?? -1,
|
||||
};
|
||||
});
|
||||
|
||||
const frame = await page.locator(".viewer-canvas").boundingBox();
|
||||
if (!frame) throw new Error("viewer canvas is missing");
|
||||
const center = { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 };
|
||||
const transform = page.locator(".viewer-transform");
|
||||
await page.mouse.dblclick(center.x + 48, center.y - 32, { delay: 70 });
|
||||
await page.waitForFunction(() => document.querySelector(".viewer-transform")?.getAttribute("data-zoomed") === "true");
|
||||
const doubleClickZoom = await transform.evaluate((element) => getComputedStyle(element).transform);
|
||||
await page.waitForTimeout(700);
|
||||
const zoomedLayout = await page.evaluate(() => {
|
||||
const frame = document.querySelector(".viewer-image-frame");
|
||||
const transform = document.querySelector(".viewer-transform");
|
||||
if (!frame || !transform) return { frame: [0, 0, 0, 0], scale: 0, rasterScale: 0 };
|
||||
const rect = frame.getBoundingClientRect();
|
||||
return {
|
||||
frame: [rect.left, rect.top, rect.width, rect.height],
|
||||
scale: Number(transform.getAttribute("data-scale") ?? 0),
|
||||
rasterScale: Number(transform.getAttribute("data-raster-scale") ?? transform.getAttribute("data-rasterScale") ?? 0),
|
||||
};
|
||||
});
|
||||
await page.screenshot({ path: `/tmp/pixhelf-viewer-${target.name}-zoomed.png` });
|
||||
|
||||
await page.mouse.click(center.x, center.y);
|
||||
await page.waitForTimeout(300);
|
||||
const singleClickControls = await page.locator(".image-viewer").getAttribute("data-controls-visible");
|
||||
|
||||
await page.mouse.wheel(0, -360);
|
||||
await page.waitForTimeout(350);
|
||||
const wheelZoom = await transform.evaluate((element) => getComputedStyle(element).transform);
|
||||
|
||||
const counterBefore = await page.locator(".viewer-counter").textContent();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await page.waitForFunction((previous) => document.querySelector(".viewer-counter")?.textContent !== previous, counterBefore);
|
||||
const counterAfter = await page.locator(".viewer-counter").textContent();
|
||||
|
||||
let touchChecks = null;
|
||||
searchDisclosure.panelWidth = searchPanelOpen?.width ?? -1;
|
||||
searchDisclosure.stationaryToggleDelta = searchToggleBefore && searchToggleOpen
|
||||
? Math.hypot(
|
||||
searchToggleBefore.x - searchToggleOpen.x,
|
||||
searchToggleBefore.y - searchToggleOpen.y,
|
||||
)
|
||||
: -1;
|
||||
await page.locator("#gallery-search-field input").fill("x");
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector(".topbar-search")?.classList.contains("has-query")
|
||||
);
|
||||
searchDisclosure.hasQueryClass = true;
|
||||
await page.locator("#gallery-search-field input").fill("");
|
||||
await page.waitForFunction(() =>
|
||||
!document.querySelector(".topbar-search")?.classList.contains("has-query")
|
||||
);
|
||||
searchDisclosure.queryClassCleared = true;
|
||||
if (target.name === "mobile") {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const swipeFrame = await page.locator(".viewer-canvas").boundingBox();
|
||||
if (!swipeFrame) throw new Error("mobile viewer canvas is missing");
|
||||
const centerPoint = {
|
||||
x: swipeFrame.x + swipeFrame.width / 2,
|
||||
y: swipeFrame.y + swipeFrame.height / 2,
|
||||
};
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty("--visual-viewport-top", "24px");
|
||||
});
|
||||
await page.waitForTimeout(50);
|
||||
const compensatedTopbar = await page.locator(".topbar").boundingBox();
|
||||
const compensatedPanel = await page.locator("#gallery-search-field").boundingBox();
|
||||
searchDisclosure.compensatedTopbarDelta = compensatedTopbar
|
||||
? compensatedTopbar.y - searchDisclosure.topbarTop
|
||||
: -1;
|
||||
searchDisclosure.compensatedPanelDelta = compensatedPanel
|
||||
? compensatedPanel.y - searchDisclosure.panelTop
|
||||
: -1;
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty("--visual-viewport-top", "0px");
|
||||
});
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
await page.locator(".search-toggle").click();
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector(".search-toggle")?.getAttribute("aria-expanded") === "false"
|
||||
);
|
||||
searchDisclosure.restored = await page.locator(".search-toggle")
|
||||
.getAttribute("aria-expanded");
|
||||
searchDisclosure.panelInertClosed = await page.locator("#gallery-search-field")
|
||||
.evaluate((panel) => panel.inert);
|
||||
|
||||
await page.keyboard.press("0");
|
||||
await page.waitForTimeout(280);
|
||||
const pinchBefore = await transform.getAttribute("data-scale");
|
||||
await touchPinch(cdp, centerPoint, 82, 210);
|
||||
await page.waitForTimeout(300);
|
||||
const pinchAfter = await transform.getAttribute("data-scale");
|
||||
await page.keyboard.press("0");
|
||||
await page.waitForTimeout(280);
|
||||
const firstExploreRequest = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url());
|
||||
return url.pathname === "/api/images" &&
|
||||
url.searchParams.get("sort") === "explore" &&
|
||||
url.searchParams.get("offset") === "0";
|
||||
});
|
||||
await page.locator(".explore-toggle").click();
|
||||
const firstExplore = await firstExploreRequest;
|
||||
const firstExploreUrl = new URL(firstExplore.url());
|
||||
const firstExploreResponse = await firstExplore.response();
|
||||
await firstExploreResponse?.finished();
|
||||
await page.waitForFunction(() => !document.querySelector(".skeleton-grid"));
|
||||
|
||||
const touchCounterBefore = await page.locator(".viewer-counter").textContent();
|
||||
await touchDrag(
|
||||
cdp,
|
||||
{ x: swipeFrame.x + swipeFrame.width * 0.78, y: centerPoint.y },
|
||||
{ x: swipeFrame.x + swipeFrame.width * 0.2, y: centerPoint.y + 3 },
|
||||
const secondExploreRequest = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url());
|
||||
return url.pathname === "/api/images" &&
|
||||
url.searchParams.get("sort") === "explore" &&
|
||||
url.searchParams.get("offset") === "0";
|
||||
});
|
||||
await page.locator(".explore-toggle").click();
|
||||
const secondExplore = await secondExploreRequest;
|
||||
const secondExploreUrl = new URL(secondExplore.url());
|
||||
const secondExploreResponse = await secondExplore.response();
|
||||
await secondExploreResponse?.finished();
|
||||
await page.waitForFunction(() => !document.querySelector(".skeleton-grid"));
|
||||
|
||||
const exploration = await page.evaluate(([firstSeed, secondSeed]) => ({
|
||||
firstSeed,
|
||||
secondSeed,
|
||||
exploreMarker: getComputedStyle(
|
||||
document.querySelector(".explore-toggle"),
|
||||
"::after",
|
||||
).content,
|
||||
searchMarker: getComputedStyle(
|
||||
document.querySelector(".search-toggle"),
|
||||
"::after",
|
||||
).content,
|
||||
}), [
|
||||
firstExploreUrl.searchParams.get("seed") ?? "",
|
||||
secondExploreUrl.searchParams.get("seed") ?? "",
|
||||
]);
|
||||
|
||||
const homeRequest = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url());
|
||||
return url.pathname === "/api/images" &&
|
||||
url.searchParams.get("sort") === "name-asc" &&
|
||||
url.searchParams.get("offset") === "0";
|
||||
});
|
||||
await page.locator("a.brand").click();
|
||||
const homeResponse = await (await homeRequest).response();
|
||||
await homeResponse?.finished();
|
||||
await page.waitForFunction(() => !document.querySelector(".skeleton-grid"));
|
||||
const homeNavigation = await page.evaluate(() => ({
|
||||
href: document.querySelector("a.brand")?.getAttribute("href"),
|
||||
galleryPath: document.querySelector(
|
||||
".desktop-sidebar .sidebar-gallery-path",
|
||||
)?.textContent,
|
||||
allImagesActive: document.querySelector(".desktop-sidebar .album-link")
|
||||
?.classList.contains("active") ?? false,
|
||||
searchValue: document.querySelector("#gallery-search-field input")?.value,
|
||||
}));
|
||||
|
||||
let desktopSidebar = null;
|
||||
let mobileNavigation = null;
|
||||
if (target.name === "desktop") {
|
||||
const contentBefore = await page.locator(".content").boundingBox();
|
||||
const toggleBefore = await page.locator(".gallery-sidebar-toggle").boundingBox();
|
||||
const firstAlbumBefore = await page.locator(".desktop-sidebar .album-link").first()
|
||||
.boundingBox();
|
||||
const controlSlotBefore = await page.locator(".desktop-sidebar .sidebar-control-slot")
|
||||
.boundingBox();
|
||||
const metaBefore = await page.locator(".desktop-sidebar .sidebar-gallery-meta")
|
||||
.boundingBox();
|
||||
const galleryBefore = await page.locator(".masonry").boundingBox();
|
||||
await page.locator(".gallery-sidebar-toggle").click();
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector(".app-shell")?.getAttribute("data-sidebar-collapsed") === "true"
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(previous) => document.querySelector(".viewer-counter")?.textContent !== previous,
|
||||
touchCounterBefore,
|
||||
await page.waitForTimeout(260);
|
||||
const contentCollapsed = await page.locator(".content").boundingBox();
|
||||
const toggleCollapsed = await page.locator(".gallery-sidebar-toggle").boundingBox();
|
||||
const sidebarCollapsedBox = await page.locator(".desktop-sidebar").boundingBox();
|
||||
const galleryCollapsed = await page.locator(".masonry").boundingBox();
|
||||
desktopSidebar = await page.evaluate(() => ({
|
||||
expanded: document.querySelector(".gallery-sidebar-toggle")?.getAttribute("aria-expanded"),
|
||||
visibility: getComputedStyle(document.querySelector(".desktop-sidebar")).visibility,
|
||||
navigationVisibility: getComputedStyle(
|
||||
document.querySelector(".desktop-sidebar .album-nav"),
|
||||
).visibility,
|
||||
statusVisibility: getComputedStyle(
|
||||
document.querySelector(".desktop-sidebar .sidebar-status"),
|
||||
).visibility,
|
||||
metaVisibility: getComputedStyle(
|
||||
document.querySelector(".desktop-sidebar .sidebar-gallery-meta"),
|
||||
).visibility,
|
||||
ariaHidden: document.querySelector(".desktop-sidebar")?.getAttribute("aria-hidden"),
|
||||
stored: localStorage.getItem("pixhelf.sidebar-collapsed"),
|
||||
columns: document.querySelectorAll(".masonry-column").length,
|
||||
redundantHeadings: document.querySelectorAll(".sidebar-heading").length,
|
||||
pathDetails: document.querySelectorAll(".album-copy small").length,
|
||||
progressPanels: document.querySelectorAll(".sidebar-progress").length,
|
||||
inlineToggles: document.querySelectorAll(".desktop-sidebar-inline-toggle").length,
|
||||
galleryToggles: document.querySelectorAll(".gallery-sidebar-toggle").length,
|
||||
}));
|
||||
desktopSidebar.contentBefore = contentBefore?.x ?? -1;
|
||||
desktopSidebar.contentCollapsed = contentCollapsed?.x ?? -1;
|
||||
desktopSidebar.expandedToggleWidth = toggleBefore?.width ?? -1;
|
||||
desktopSidebar.expandedToggleHeight = toggleBefore?.height ?? -1;
|
||||
desktopSidebar.collapsedToggleWidth = toggleCollapsed?.width ?? -1;
|
||||
desktopSidebar.collapsedToggleHeight = toggleCollapsed?.height ?? -1;
|
||||
desktopSidebar.albumClearance = controlSlotBefore && firstAlbumBefore
|
||||
? firstAlbumBefore.y - (controlSlotBefore.y + controlSlotBefore.height)
|
||||
: -1;
|
||||
desktopSidebar.toggleControlInsetX = toggleBefore && controlSlotBefore
|
||||
? toggleBefore.x - controlSlotBefore.x
|
||||
: -1;
|
||||
desktopSidebar.toggleControlInsetY = toggleBefore && controlSlotBefore
|
||||
? toggleBefore.y - controlSlotBefore.y
|
||||
: -1;
|
||||
desktopSidebar.stationaryToggleDelta = toggleBefore && toggleCollapsed
|
||||
? Math.hypot(toggleBefore.x - toggleCollapsed.x, toggleBefore.y - toggleCollapsed.y)
|
||||
: -1;
|
||||
desktopSidebar.metaIsBelowAlbums = metaBefore && firstAlbumBefore
|
||||
? metaBefore.y > firstAlbumBefore.y + firstAlbumBefore.height
|
||||
: false;
|
||||
desktopSidebar.expandedGalleryInsetX = toggleBefore && galleryBefore
|
||||
? toggleBefore.x - galleryBefore.x
|
||||
: -1;
|
||||
desktopSidebar.expandedGalleryInsetY = toggleBefore && galleryBefore
|
||||
? toggleBefore.y - galleryBefore.y
|
||||
: -1;
|
||||
desktopSidebar.collapsedGalleryInsetX = toggleCollapsed && galleryCollapsed
|
||||
? toggleCollapsed.x - galleryCollapsed.x
|
||||
: -1;
|
||||
desktopSidebar.collapsedGalleryInsetY = toggleCollapsed && galleryCollapsed
|
||||
? toggleCollapsed.y - galleryCollapsed.y
|
||||
: -1;
|
||||
desktopSidebar.expandedGalleryOverlap = toggleBefore && galleryBefore
|
||||
? !(
|
||||
toggleBefore.x + toggleBefore.width <= galleryBefore.x ||
|
||||
galleryBefore.x + galleryBefore.width <= toggleBefore.x ||
|
||||
toggleBefore.y + toggleBefore.height <= galleryBefore.y ||
|
||||
galleryBefore.y + galleryBefore.height <= toggleBefore.y
|
||||
)
|
||||
: false;
|
||||
desktopSidebar.galleryOverlap = toggleCollapsed && galleryCollapsed
|
||||
? !(
|
||||
toggleCollapsed.x + toggleCollapsed.width <= galleryCollapsed.x ||
|
||||
galleryCollapsed.x + galleryCollapsed.width <= toggleCollapsed.x ||
|
||||
toggleCollapsed.y + toggleCollapsed.height <= galleryCollapsed.y ||
|
||||
galleryCollapsed.y + galleryCollapsed.height <= toggleCollapsed.y
|
||||
)
|
||||
: true;
|
||||
desktopSidebar.sidebarRight = sidebarCollapsedBox
|
||||
? sidebarCollapsedBox.x + sidebarCollapsedBox.width
|
||||
: -1;
|
||||
|
||||
await page.locator(".gallery-sidebar-toggle").click();
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector(".app-shell")?.getAttribute("data-sidebar-collapsed") === "false"
|
||||
);
|
||||
const touchCounterAfter = await page.locator(".viewer-counter").textContent();
|
||||
|
||||
const controlsBeforeTap = await page.locator(".image-viewer").getAttribute("data-controls-visible");
|
||||
await touchTap(cdp, { x: 18, y: centerPoint.y });
|
||||
await page.waitForTimeout(330);
|
||||
const viewerStillOpenAfterBlankTap = await page.locator(".image-viewer").count();
|
||||
const controlsAfterTap = await page.locator(".image-viewer").getAttribute("data-controls-visible");
|
||||
|
||||
await touchDrag(
|
||||
cdp,
|
||||
{ x: centerPoint.x, y: swipeFrame.y + swipeFrame.height * 0.34 },
|
||||
{ x: centerPoint.x + 3, y: swipeFrame.y + swipeFrame.height * 0.7 },
|
||||
);
|
||||
await page.waitForSelector(".image-viewer", { state: "detached" });
|
||||
|
||||
touchChecks = {
|
||||
pinchBefore: Number(pinchBefore),
|
||||
pinchAfter: Number(pinchAfter),
|
||||
touchCounterBefore,
|
||||
touchCounterAfter,
|
||||
controlsBeforeTap,
|
||||
controlsAfterTap,
|
||||
viewerStillOpenAfterBlankTap,
|
||||
dismissDetached: true,
|
||||
};
|
||||
await page.waitForTimeout(260);
|
||||
const toggleRestored = await page.locator(".gallery-sidebar-toggle").boundingBox();
|
||||
desktopSidebar.restoredToggleDelta = toggleBefore && toggleRestored
|
||||
? Math.hypot(toggleBefore.x - toggleRestored.x, toggleBefore.y - toggleRestored.y)
|
||||
: -1;
|
||||
desktopSidebar.restored = await page.locator(".gallery-sidebar-toggle")
|
||||
.getAttribute("aria-expanded");
|
||||
} else {
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForSelector(".image-viewer", { state: "detached" });
|
||||
await page.evaluate(() => window.scrollTo({ top: 320, behavior: "auto" }));
|
||||
await page.waitForTimeout(100);
|
||||
const fixedTop = await page.locator(".topbar").boundingBox();
|
||||
await page.evaluate(() => window.scrollTo({ top: 0, behavior: "auto" }));
|
||||
const navigationToggleBefore = await page.locator(".gallery-sidebar-toggle").boundingBox();
|
||||
const galleryBefore = await page.locator(".masonry").boundingBox();
|
||||
await page.locator(".gallery-sidebar-toggle").click();
|
||||
await page.waitForSelector(".mobile-nav-layer");
|
||||
await page.waitForTimeout(240);
|
||||
const navigationToggleOpen = await page.locator(".gallery-sidebar-toggle").boundingBox();
|
||||
const navigationLayer = await page.locator(".mobile-nav-layer").boundingBox();
|
||||
const navigationDrawer = await page.locator(".mobile-sidebar").boundingBox();
|
||||
const controlSlot = await page.locator(".mobile-sidebar .sidebar-control-slot").boundingBox();
|
||||
const mobileMeta = await page.locator(".mobile-sidebar .sidebar-gallery-meta").boundingBox();
|
||||
const mobileStatus = await page.locator(".mobile-sidebar .sidebar-status").boundingBox();
|
||||
const topbar = await page.locator(".topbar").boundingBox();
|
||||
mobileNavigation = {
|
||||
opened: await page.locator(".mobile-nav-layer").count(),
|
||||
fixedTop: fixedTop?.y ?? -1,
|
||||
expanded: await page.locator(".gallery-sidebar-toggle").getAttribute("aria-expanded"),
|
||||
internalToggles: await page.locator(".mobile-sidebar .sidebar-toggle-button").count(),
|
||||
metaRows: await page.locator(".mobile-sidebar .sidebar-gallery-meta").count(),
|
||||
galleryPath: await page.locator(".mobile-sidebar .sidebar-gallery-path").textContent(),
|
||||
galleryCount: await page.locator(".mobile-sidebar .sidebar-gallery-count").textContent(),
|
||||
layerTopGap: navigationLayer && topbar
|
||||
? navigationLayer.y - (topbar.y + topbar.height)
|
||||
: -1,
|
||||
toggleWidth: navigationToggleBefore?.width ?? -1,
|
||||
toggleHeight: navigationToggleBefore?.height ?? -1,
|
||||
expandedToggleWidth: navigationToggleOpen?.width ?? -1,
|
||||
galleryInsetX: navigationToggleBefore && galleryBefore
|
||||
? navigationToggleBefore.x - galleryBefore.x
|
||||
: -1,
|
||||
galleryInsetY: navigationToggleBefore && galleryBefore
|
||||
? navigationToggleBefore.y - galleryBefore.y
|
||||
: -1,
|
||||
galleryOverlap: navigationToggleBefore && galleryBefore
|
||||
? !(
|
||||
navigationToggleBefore.x + navigationToggleBefore.width <= galleryBefore.x ||
|
||||
galleryBefore.x + galleryBefore.width <= navigationToggleBefore.x ||
|
||||
navigationToggleBefore.y + navigationToggleBefore.height <= galleryBefore.y ||
|
||||
galleryBefore.y + galleryBefore.height <= navigationToggleBefore.y
|
||||
)
|
||||
: -1,
|
||||
stationaryToggleDelta: navigationToggleBefore && navigationToggleOpen
|
||||
? Math.hypot(
|
||||
navigationToggleBefore.x - navigationToggleOpen.x,
|
||||
navigationToggleBefore.y - navigationToggleOpen.y,
|
||||
)
|
||||
: -1,
|
||||
toggleControlInsetX: navigationToggleOpen && controlSlot
|
||||
? navigationToggleOpen.x - controlSlot.x
|
||||
: -1,
|
||||
toggleControlInsetY: navigationToggleOpen && controlSlot
|
||||
? navigationToggleOpen.y - controlSlot.y
|
||||
: -1,
|
||||
metaBottomGap: mobileMeta ? target.height - (mobileMeta.y + mobileMeta.height) : -1,
|
||||
metaStatusGap: mobileMeta && mobileStatus
|
||||
? mobileMeta.y - (mobileStatus.y + mobileStatus.height)
|
||||
: -1,
|
||||
drawerWidth: navigationDrawer?.width ?? -1,
|
||||
};
|
||||
await page.locator(".gallery-sidebar-toggle").click();
|
||||
await page.waitForSelector(".mobile-nav-layer", { state: "detached" });
|
||||
mobileNavigation.closed = await page.locator(".mobile-nav-layer").count();
|
||||
mobileNavigation.restored = await page.locator(".gallery-sidebar-toggle")
|
||||
.getAttribute("aria-expanded");
|
||||
}
|
||||
|
||||
const result = {
|
||||
name: target.name,
|
||||
...layout,
|
||||
viewerLayout,
|
||||
zoomedLayout,
|
||||
doubleClickZoom,
|
||||
wheelZoom,
|
||||
singleClickControls,
|
||||
counterBefore,
|
||||
counterAfter,
|
||||
touchChecks,
|
||||
dialogsAfterImageClick,
|
||||
searchDisclosure,
|
||||
exploration,
|
||||
homeNavigation,
|
||||
desktopSidebar,
|
||||
mobileNavigation,
|
||||
browserErrors,
|
||||
};
|
||||
results.push(result);
|
||||
console.log(JSON.stringify(result));
|
||||
|
||||
const expectedColumns = target.name === "desktop" ? 5 : 2;
|
||||
const viewerContained = viewerLayout.visible &&
|
||||
viewerLayout.frame[0] >= -1 &&
|
||||
viewerLayout.frame[1] >= -1 &&
|
||||
viewerLayout.frame[2] <= target.width + 1 &&
|
||||
viewerLayout.frame[3] <= target.height + 1;
|
||||
if (
|
||||
layout.horizontalOverflow ||
|
||||
layout.brokenVisibleImages ||
|
||||
layout.masonryColumns !== expectedColumns ||
|
||||
!viewerContained ||
|
||||
Number(viewerLayout.imageOpacity) < 0.98 ||
|
||||
viewerLayout.objectFit !== "contain" ||
|
||||
!viewerLayout.noLegacyLightbox ||
|
||||
!doubleClickZoom.includes("matrix") ||
|
||||
!wheelZoom.includes("matrix") ||
|
||||
zoomedLayout.scale <= 1.01 ||
|
||||
zoomedLayout.rasterScale <= 1.01 ||
|
||||
zoomedLayout.frame[2] <= (viewerLayout.frame[2] - viewerLayout.frame[0]) * 1.1 ||
|
||||
Math.abs(zoomedLayout.frame[2] / (viewerLayout.frame[2] - viewerLayout.frame[0]) - zoomedLayout.scale) > 0.08 ||
|
||||
singleClickControls !== "false" ||
|
||||
counterBefore === counterAfter ||
|
||||
(target.name === "mobile" && (
|
||||
!touchChecks ||
|
||||
touchChecks.pinchAfter <= touchChecks.pinchBefore + 0.2 ||
|
||||
touchChecks.touchCounterBefore === touchChecks.touchCounterAfter ||
|
||||
touchChecks.viewerStillOpenAfterBlankTap !== 1 ||
|
||||
touchChecks.controlsBeforeTap === touchChecks.controlsAfterTap ||
|
||||
!touchChecks.dismissDetached
|
||||
layout.cardTags.some((tag) => tag !== "FIGURE") ||
|
||||
layout.dialogs !== 0 ||
|
||||
dialogsAfterImageClick !== 0 ||
|
||||
searchDisclosure.expanded !== "true" ||
|
||||
!searchDisclosure.focused ||
|
||||
searchDisclosure.panelRole !== "group" ||
|
||||
searchDisclosure.panelInert ||
|
||||
(target.name === "desktop" && searchDisclosure.leadingGap < 6) ||
|
||||
searchDisclosure.panelWidth < (target.name === "desktop" ? 250 : 200) ||
|
||||
searchDisclosure.stationaryToggleDelta < 0 ||
|
||||
searchDisclosure.stationaryToggleDelta > 0.5 ||
|
||||
searchDisclosure.restored !== "false" ||
|
||||
!searchDisclosure.panelInertClosed ||
|
||||
!searchDisclosure.hasQueryClass ||
|
||||
!searchDisclosure.queryClassCleared ||
|
||||
searchDisclosure.panelTop - searchDisclosure.topbarBottom < 6 ||
|
||||
exploration.firstSeed.length !== 32 ||
|
||||
exploration.secondSeed.length !== 32 ||
|
||||
exploration.firstSeed === exploration.secondSeed ||
|
||||
exploration.exploreMarker !== "none" ||
|
||||
exploration.searchMarker !== "none" ||
|
||||
homeNavigation.href !== "/" ||
|
||||
homeNavigation.galleryPath !== "/" ||
|
||||
!homeNavigation.allImagesActive ||
|
||||
homeNavigation.searchValue !== "" ||
|
||||
layout.topbarSearches !== 1 ||
|
||||
layout.brandHomeLinks !== 1 ||
|
||||
layout.gallerySidebarToggles !== 1 ||
|
||||
layout.galleryToggleOpacity < 0.4 ||
|
||||
layout.galleryToggleOpacity > 0.75 ||
|
||||
layout.topbarSidebarToggles !== 0 ||
|
||||
layout.contentSearches !== 0 ||
|
||||
layout.contentInfoRows !== 0 ||
|
||||
layout.sortControls !== 0 ||
|
||||
layout.topbarSearchRightGap < 0 ||
|
||||
layout.topbarSearchRightGap > 20 ||
|
||||
layout.exploreButtons !== 1 ||
|
||||
layout.exploreSearchGap < 4 ||
|
||||
layout.exploreSearchGap > 8 ||
|
||||
layout.sidebarStatuses !== 1 ||
|
||||
layout.sidebarGalleryMetas !== 1 ||
|
||||
!layout.galleryPath.startsWith("/") ||
|
||||
!layout.galleryCount.endsWith("张图片") ||
|
||||
layout.galleryMetaRowDelta < 0 ||
|
||||
layout.galleryMetaRowDelta > 2 ||
|
||||
(target.name === "desktop" && (
|
||||
!desktopSidebar ||
|
||||
desktopSidebar.expanded !== "false" ||
|
||||
desktopSidebar.visibility !== "visible" ||
|
||||
desktopSidebar.navigationVisibility !== "hidden" ||
|
||||
desktopSidebar.statusVisibility !== "hidden" ||
|
||||
desktopSidebar.metaVisibility !== "hidden" ||
|
||||
desktopSidebar.ariaHidden !== "true" ||
|
||||
desktopSidebar.stored !== "true" ||
|
||||
desktopSidebar.columns !== 6 ||
|
||||
desktopSidebar.redundantHeadings !== 0 ||
|
||||
desktopSidebar.pathDetails !== 0 ||
|
||||
desktopSidebar.progressPanels !== 0 ||
|
||||
desktopSidebar.inlineToggles !== 0 ||
|
||||
desktopSidebar.galleryToggles !== 1 ||
|
||||
Math.abs(desktopSidebar.expandedToggleWidth - (desktopSidebar.contentBefore - 20)) > 1 ||
|
||||
Math.abs(desktopSidebar.expandedToggleHeight - 36) > 1 ||
|
||||
Math.abs(desktopSidebar.collapsedToggleWidth - 40) > 1 ||
|
||||
Math.abs(desktopSidebar.collapsedToggleHeight - 36) > 1 ||
|
||||
desktopSidebar.albumClearance < 8 ||
|
||||
desktopSidebar.toggleControlInsetX < 8 ||
|
||||
desktopSidebar.toggleControlInsetX > 12 ||
|
||||
desktopSidebar.toggleControlInsetY < 4 ||
|
||||
desktopSidebar.toggleControlInsetY > 8 ||
|
||||
desktopSidebar.stationaryToggleDelta < 0 ||
|
||||
desktopSidebar.stationaryToggleDelta > 1 ||
|
||||
!desktopSidebar.metaIsBelowAlbums ||
|
||||
desktopSidebar.expandedGalleryOverlap ||
|
||||
!desktopSidebar.galleryOverlap ||
|
||||
Math.abs(desktopSidebar.sidebarRight) > 1 ||
|
||||
desktopSidebar.contentBefore < 200 ||
|
||||
Math.abs(desktopSidebar.contentCollapsed) > 1 ||
|
||||
desktopSidebar.restoredToggleDelta < 0 ||
|
||||
desktopSidebar.restoredToggleDelta > 1 ||
|
||||
layout.sidebarMetaBottomGap < 0 ||
|
||||
layout.sidebarMetaBottomGap > 1 ||
|
||||
Math.abs(layout.sidebarMetaStatusGap) > 1 ||
|
||||
desktopSidebar.restored !== "true"
|
||||
)) ||
|
||||
(target.name === "mobile" && (
|
||||
layout.topbarPosition !== "fixed" ||
|
||||
Math.abs(layout.topbarTop) > 1 ||
|
||||
Math.abs(searchDisclosure.compensatedTopbarDelta - 24) > 1 ||
|
||||
Math.abs(searchDisclosure.compensatedPanelDelta - 24) > 1 ||
|
||||
mobileNavigation?.opened !== 1 ||
|
||||
Math.abs(mobileNavigation.fixedTop) > 1 ||
|
||||
mobileNavigation.expanded !== "true" ||
|
||||
mobileNavigation.internalToggles !== 0 ||
|
||||
mobileNavigation.metaRows !== 1 ||
|
||||
!mobileNavigation.galleryPath?.startsWith("/") ||
|
||||
!mobileNavigation.galleryCount?.endsWith("张图片") ||
|
||||
Math.abs(mobileNavigation.layerTopGap) > 1 ||
|
||||
Math.abs(mobileNavigation.toggleWidth - 40) > 1 ||
|
||||
Math.abs(mobileNavigation.toggleHeight - 36) > 1 ||
|
||||
Math.abs(mobileNavigation.expandedToggleWidth - (mobileNavigation.drawerWidth - 20)) > 1 ||
|
||||
!mobileNavigation.galleryOverlap ||
|
||||
mobileNavigation.stationaryToggleDelta < 0 ||
|
||||
mobileNavigation.stationaryToggleDelta > 1 ||
|
||||
mobileNavigation.toggleControlInsetX < 8 ||
|
||||
mobileNavigation.toggleControlInsetX > 12 ||
|
||||
mobileNavigation.toggleControlInsetY < 4 ||
|
||||
mobileNavigation.toggleControlInsetY > 8 ||
|
||||
mobileNavigation.metaBottomGap < 0 ||
|
||||
mobileNavigation.metaBottomGap > 1 ||
|
||||
Math.abs(mobileNavigation.metaStatusGap) > 1 ||
|
||||
mobileNavigation.drawerWidth < 200 ||
|
||||
mobileNavigation.closed !== 0 ||
|
||||
mobileNavigation.restored !== "false" ||
|
||||
layout.cardNameDisplay === "none" ||
|
||||
layout.cardNameOpacity > 0.01 ||
|
||||
layout.cardNameWhiteSpace !== "nowrap"
|
||||
)) ||
|
||||
(target.name === "desktop" && layout.topbarPosition !== "fixed") ||
|
||||
browserErrors.length
|
||||
) {
|
||||
throw new Error(`visual check failed: ${JSON.stringify(result)}`);
|
||||
|
||||
+646
-223
File diff suppressed because it is too large.
Load diff
@@ -1,855 +0,0 @@
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
ImageOff,
|
||||
LoaderCircle,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Minus,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Scan,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type SyntheticEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { GalleryImage } from "./types";
|
||||
import { type Point, useViewerEngine } from "./viewer/useViewerEngine";
|
||||
|
||||
const DOUBLE_TAP_DELAY = 230;
|
||||
const VIEWER_PREVIEW_EDGE = 2560;
|
||||
|
||||
type SourceStatus = "loading" | "ready" | "failed";
|
||||
|
||||
interface SourceState {
|
||||
imageId: string;
|
||||
status: SourceStatus;
|
||||
}
|
||||
|
||||
interface PendingTap extends Point {
|
||||
time: number;
|
||||
pointerType: string;
|
||||
}
|
||||
|
||||
interface RetryState {
|
||||
imageId: string;
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
interface ImageViewerProps {
|
||||
images: GalleryImage[];
|
||||
index: number;
|
||||
total: number;
|
||||
onIndexChange: (index: number) => void;
|
||||
onNeedMore: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface ViewportSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function viewportSize(): ViewportSize {
|
||||
return {
|
||||
width: Math.round(window.visualViewport?.width ?? window.innerWidth),
|
||||
height: Math.round(window.visualViewport?.height ?? window.innerHeight),
|
||||
};
|
||||
}
|
||||
|
||||
function useViewportSize(): ViewportSize {
|
||||
const [size, setSize] = useState(viewportSize);
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
const update = () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
frame = window.requestAnimationFrame(() => setSize(viewportSize()));
|
||||
};
|
||||
window.addEventListener("resize", update);
|
||||
window.visualViewport?.addEventListener("resize", update);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.removeEventListener("resize", update);
|
||||
window.visualViewport?.removeEventListener("resize", update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
function fittedSize(image: GalleryImage, viewport: ViewportSize) {
|
||||
const compact = viewport.width <= 720;
|
||||
const availableWidth = Math.max(1, viewport.width - (compact ? 8 : 72));
|
||||
const availableHeight = Math.max(1, viewport.height - (compact ? 8 : 40));
|
||||
const ratio = Math.min(
|
||||
availableWidth / Math.max(image.width, 1),
|
||||
availableHeight / Math.max(image.height, 1),
|
||||
1,
|
||||
);
|
||||
return {
|
||||
width: Math.max(1, Math.round(image.width * ratio)),
|
||||
height: Math.max(1, Math.round(image.height * ratio)),
|
||||
};
|
||||
}
|
||||
|
||||
function maximumZoom(
|
||||
image: GalleryImage,
|
||||
frame: ViewportSize,
|
||||
previewReady: boolean,
|
||||
originalReady: boolean,
|
||||
) {
|
||||
if (!previewReady && !originalReady) return 1;
|
||||
const sourceEdge = originalReady
|
||||
? Math.max(image.width, image.height)
|
||||
: Math.min(VIEWER_PREVIEW_EDGE, Math.max(image.width, image.height));
|
||||
const nativeScale = sourceEdge / Math.max(frame.width, frame.height, 1);
|
||||
return Math.max(1, Math.min(6, nativeScale));
|
||||
}
|
||||
|
||||
function formatCount(value: number): string {
|
||||
return new Intl.NumberFormat("zh-CN").format(value);
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024 * 1024) return `${Math.max(1, Math.round(value / 1024))} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export default function ImageViewer({
|
||||
images,
|
||||
index,
|
||||
total,
|
||||
onIndexChange,
|
||||
onNeedMore,
|
||||
onClose,
|
||||
}: ImageViewerProps) {
|
||||
const image = images[index];
|
||||
const previous = images[index - 1] ?? null;
|
||||
const next = images[index + 1] ?? null;
|
||||
const viewport = useViewportSize();
|
||||
const compact = viewport.width <= 720;
|
||||
const frameSize = useMemo(() => fittedSize(image, viewport), [image, viewport]);
|
||||
const slideSpan = viewport.width + (compact ? 12 : 28);
|
||||
const [sourceState, setSourceState] = useState<SourceState>({
|
||||
imageId: image.id,
|
||||
status: "loading",
|
||||
});
|
||||
const [previewState, setPreviewState] = useState<SourceState>({
|
||||
imageId: image.id,
|
||||
status: "loading",
|
||||
});
|
||||
const [thumbnailState, setThumbnailState] = useState<SourceState>({
|
||||
imageId: image.id,
|
||||
status: "loading",
|
||||
});
|
||||
// Keep the large source out of the network queue while the sharp preview is
|
||||
// being fetched. A short timeout keeps the original a reliable fallback on
|
||||
// slow or unavailable preview endpoints.
|
||||
const [originalGate, setOriginalGate] = useState({ imageId: image.id, open: false });
|
||||
const [retryState, setRetryState] = useState<RetryState>({
|
||||
imageId: image.id,
|
||||
attempt: 0,
|
||||
});
|
||||
const sourceAttempt = retryState.imageId === image.id ? retryState.attempt : 0;
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(Boolean(document.fullscreenElement));
|
||||
const [pendingNext, setPendingNext] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const currentImageIdRef = useRef(image.id);
|
||||
const sourceAttemptRef = useRef(sourceAttempt);
|
||||
const closeTimerRef = useRef(0);
|
||||
const controlsTimerRef = useRef(0);
|
||||
const tapTimerRef = useRef(0);
|
||||
const pendingTimerRef = useRef(0);
|
||||
const pendingRetryRef = useRef(0);
|
||||
const tapRef = useRef<PendingTap | null>(null);
|
||||
const tapHandlerRef = useRef<(point: Point, pointerType: string) => void>(() => undefined);
|
||||
currentImageIdRef.current = image.id;
|
||||
sourceAttemptRef.current = sourceAttempt;
|
||||
|
||||
const originalReady = sourceState.imageId === image.id && sourceState.status === "ready";
|
||||
const originalFailed = sourceState.imageId === image.id && sourceState.status === "failed";
|
||||
const previewReady = previewState.imageId === image.id && previewState.status === "ready";
|
||||
const thumbnailReady = thumbnailState.imageId === image.id && thumbnailState.status === "ready";
|
||||
const originalEnabled = originalGate.imageId === image.id && originalGate.open;
|
||||
const maxZoom = useMemo(
|
||||
() => maximumZoom(
|
||||
image,
|
||||
frameSize,
|
||||
previewReady,
|
||||
originalReady,
|
||||
),
|
||||
[frameSize, image, originalReady, previewReady],
|
||||
);
|
||||
|
||||
const clearTap = useCallback(() => {
|
||||
window.clearTimeout(tapTimerRef.current);
|
||||
tapTimerRef.current = 0;
|
||||
tapRef.current = null;
|
||||
}, []);
|
||||
|
||||
const scheduleControlsHide = useCallback(() => {
|
||||
window.clearTimeout(controlsTimerRef.current);
|
||||
controlsTimerRef.current = window.setTimeout(() => {
|
||||
const root = rootRef.current;
|
||||
if (root?.querySelector(".viewer-chrome:focus-within")) return;
|
||||
setControlsVisible(false);
|
||||
}, compact ? 1700 : 1400);
|
||||
}, [compact]);
|
||||
|
||||
const revealControls = useCallback(() => {
|
||||
setControlsVisible(true);
|
||||
scheduleControlsHide();
|
||||
}, [scheduleControlsHide]);
|
||||
|
||||
const requestOriginal = useCallback(() => {
|
||||
setOriginalGate((current) =>
|
||||
current.imageId === image.id && !current.open
|
||||
? { imageId: image.id, open: true }
|
||||
: current,
|
||||
);
|
||||
}, [image.id]);
|
||||
|
||||
const requestClose = useCallback(() => {
|
||||
if (closing) return;
|
||||
clearTap();
|
||||
window.clearTimeout(controlsTimerRef.current);
|
||||
setClosing(true);
|
||||
if (document.fullscreenElement === rootRef.current) {
|
||||
void document.exitFullscreen().catch(() => undefined);
|
||||
}
|
||||
closeTimerRef.current = window.setTimeout(onClose, 170);
|
||||
}, [clearTap, closing, onClose]);
|
||||
|
||||
const handleNavigate = useCallback((direction: -1 | 1) => {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= images.length) return;
|
||||
clearTap();
|
||||
setPendingNext(false);
|
||||
onIndexChange(target);
|
||||
revealControls();
|
||||
}, [clearTap, images.length, index, onIndexChange, revealControls]);
|
||||
|
||||
const handleEdge = useCallback((direction: -1 | 1) => {
|
||||
if (direction > 0 && index + 1 < total && !pendingNext) {
|
||||
setPendingNext(true);
|
||||
onNeedMore();
|
||||
}
|
||||
}, [index, onNeedMore, pendingNext, total]);
|
||||
|
||||
const engine = useViewerEngine({
|
||||
imageId: image.id,
|
||||
frameSize,
|
||||
maximumZoom: maxZoom,
|
||||
canPrevious: Boolean(previous),
|
||||
canNext: Boolean(next),
|
||||
onNavigate: handleNavigate,
|
||||
onEdge: handleEdge,
|
||||
onDismiss: requestClose,
|
||||
onTap: (point, pointerType) => tapHandlerRef.current(point, pointerType),
|
||||
onDragStart: clearTap,
|
||||
onInteraction: () => {
|
||||
revealControls();
|
||||
requestOriginal();
|
||||
},
|
||||
});
|
||||
|
||||
const commitSingleTap = useCallback(() => {
|
||||
setControlsVisible((visible) => {
|
||||
if (visible) window.clearTimeout(controlsTimerRef.current);
|
||||
else scheduleControlsHide();
|
||||
return !visible;
|
||||
});
|
||||
}, [scheduleControlsHide]);
|
||||
|
||||
const handleTap = useCallback((point: Point, pointerType: string) => {
|
||||
const now = performance.now();
|
||||
const pending = tapRef.current;
|
||||
const tapDelay = pointerType === "touch" ? DOUBLE_TAP_DELAY : 200;
|
||||
const threshold = pointerType === "touch" ? 34 : 24;
|
||||
const compatible = pending &&
|
||||
pending.pointerType === pointerType &&
|
||||
now - pending.time <= tapDelay + 40 &&
|
||||
Math.hypot(pending.x - point.x, pending.y - point.y) <= threshold;
|
||||
|
||||
if (compatible) {
|
||||
clearTap();
|
||||
engine.toggleZoomAt(point);
|
||||
return;
|
||||
}
|
||||
if (pending) {
|
||||
window.clearTimeout(tapTimerRef.current);
|
||||
commitSingleTap();
|
||||
}
|
||||
tapRef.current = { ...point, time: now, pointerType };
|
||||
tapTimerRef.current = window.setTimeout(() => {
|
||||
tapRef.current = null;
|
||||
tapTimerRef.current = 0;
|
||||
commitSingleTap();
|
||||
}, tapDelay);
|
||||
}, [clearTap, commitSingleTap, engine]);
|
||||
|
||||
tapHandlerRef.current = handleTap;
|
||||
|
||||
const canvasHandlers = {
|
||||
onPointerDown: engine.handlePointerDown,
|
||||
onPointerMove: engine.handlePointerMove,
|
||||
onPointerUp: engine.handlePointerUp,
|
||||
onPointerCancel: engine.handlePointerCancel,
|
||||
onLostPointerCapture: engine.handleLostPointerCapture,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setAnnouncement(`${image.name},第 ${index + 1} 张,共 ${total} 张`);
|
||||
}, [image.id, image.name, index, total]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingNext || index + 1 >= images.length) return;
|
||||
let cancelled = false;
|
||||
const advance = () => {
|
||||
if (cancelled) return;
|
||||
if (engine.navigate(1)) {
|
||||
setPendingNext(false);
|
||||
return;
|
||||
}
|
||||
pendingRetryRef.current = window.setTimeout(advance, 120);
|
||||
};
|
||||
advance();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(pendingRetryRef.current);
|
||||
};
|
||||
}, [engine.navigate, images.length, index, pendingNext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingNext) return;
|
||||
window.clearTimeout(pendingTimerRef.current);
|
||||
pendingTimerRef.current = window.setTimeout(() => setPendingNext(false), 8000);
|
||||
return () => window.clearTimeout(pendingTimerRef.current);
|
||||
}, [pendingNext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (index >= images.length - 4 && index + 1 < total) onNeedMore();
|
||||
}, [images.length, index, onNeedMore, total]);
|
||||
|
||||
const handleOriginalLoad = useCallback(async (event: SyntheticEvent<HTMLImageElement>) => {
|
||||
const element = event.currentTarget;
|
||||
const imageId = element.dataset.imageId;
|
||||
const attempt = Number(element.dataset.sourceAttempt ?? -1);
|
||||
try {
|
||||
await element.decode();
|
||||
} catch {
|
||||
// A completed image can still reject decode on some WebKit versions.
|
||||
}
|
||||
if (
|
||||
imageId &&
|
||||
imageId === currentImageIdRef.current &&
|
||||
attempt === sourceAttemptRef.current &&
|
||||
element.isConnected &&
|
||||
element.complete &&
|
||||
element.naturalWidth > 0
|
||||
) {
|
||||
setSourceState({ imageId, status: "ready" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleThumbnailLoad = useCallback((event: SyntheticEvent<HTMLImageElement>) => {
|
||||
const imageId = event.currentTarget.dataset.imageId;
|
||||
if (
|
||||
imageId &&
|
||||
imageId === currentImageIdRef.current &&
|
||||
event.currentTarget.isConnected &&
|
||||
event.currentTarget.complete &&
|
||||
event.currentTarget.naturalWidth > 0
|
||||
) {
|
||||
setThumbnailState({ imageId, status: "ready" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePreviewLoad = useCallback(async (event: SyntheticEvent<HTMLImageElement>) => {
|
||||
const element = event.currentTarget;
|
||||
const imageId = element.dataset.imageId;
|
||||
try {
|
||||
await element.decode();
|
||||
} catch {
|
||||
// A completed image can still reject decode on some WebKit versions.
|
||||
}
|
||||
if (
|
||||
imageId &&
|
||||
imageId === currentImageIdRef.current &&
|
||||
element.isConnected &&
|
||||
element.complete &&
|
||||
element.naturalWidth > 0
|
||||
) {
|
||||
setPreviewState({ imageId, status: "ready" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePreviewError = useCallback((event: SyntheticEvent<HTMLImageElement>) => {
|
||||
const imageId = event.currentTarget.dataset.imageId;
|
||||
if (imageId && imageId === currentImageIdRef.current && event.currentTarget.isConnected) {
|
||||
setPreviewState({ imageId, status: "failed" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSourceState({ imageId: image.id, status: "loading" });
|
||||
setPreviewState({ imageId: image.id, status: "loading" });
|
||||
setThumbnailState({ imageId: image.id, status: "loading" });
|
||||
setOriginalGate({ imageId: image.id, open: false });
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setOriginalGate((current) =>
|
||||
current.imageId === image.id ? { imageId: image.id, open: true } : current,
|
||||
);
|
||||
}, 350);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [image.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const previewFailed = previewState.imageId === image.id && previewState.status === "failed";
|
||||
if (!previewReady && !previewFailed) return;
|
||||
setOriginalGate((current) =>
|
||||
current.imageId === image.id ? { imageId: image.id, open: true } : current,
|
||||
);
|
||||
}, [image.id, previewReady, previewState.status]);
|
||||
|
||||
const handleOriginalError = useCallback((event: SyntheticEvent<HTMLImageElement>) => {
|
||||
const element = event.currentTarget;
|
||||
const imageId = element.dataset.imageId;
|
||||
const attempt = Number(element.dataset.sourceAttempt ?? -1);
|
||||
if (
|
||||
imageId &&
|
||||
imageId === currentImageIdRef.current &&
|
||||
attempt === sourceAttemptRef.current &&
|
||||
element.isConnected
|
||||
) {
|
||||
setSourceState({ imageId, status: "failed" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewReady || !next?.previewUrl) return;
|
||||
let cancelled = false;
|
||||
const prefetchTimer = window.setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
const preloader = new Image();
|
||||
preloader.decoding = "async";
|
||||
preloader.fetchPriority = "low";
|
||||
preloader.src = next.previewUrl;
|
||||
}, 280);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(prefetchTimer);
|
||||
};
|
||||
}, [image.id, next?.previewUrl, previewReady]);
|
||||
|
||||
const retryOriginal = useCallback(() => {
|
||||
setSourceState({ imageId: image.id, status: "loading" });
|
||||
setRetryState((current) => ({
|
||||
imageId: image.id,
|
||||
attempt: current.imageId === image.id ? current.attempt + 1 : 1,
|
||||
}));
|
||||
revealControls();
|
||||
}, [image.id, revealControls]);
|
||||
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
try {
|
||||
if (document.fullscreenElement) await document.exitFullscreen();
|
||||
else await rootRef.current?.requestFullscreen();
|
||||
} catch {
|
||||
// Browsers may deny fullscreen in embedded contexts.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setFullscreen(document.fullscreenElement === rootRef.current);
|
||||
document.addEventListener("fullscreenchange", update);
|
||||
return () => document.removeEventListener("fullscreenchange", update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const previousFocus = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
const app = document.querySelector<HTMLElement>(".app-shell");
|
||||
const previousInert = app?.inert ?? false;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const previousPadding = document.body.style.paddingRight;
|
||||
const scrollbar = window.innerWidth - document.documentElement.clientWidth;
|
||||
if (app) app.inert = true;
|
||||
document.body.style.overflow = "hidden";
|
||||
if (scrollbar > 0) document.body.style.paddingRight = `${scrollbar}px`;
|
||||
const frame = window.requestAnimationFrame(() => rootRef.current?.focus());
|
||||
scheduleControlsHide();
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
if (app) app.inert = previousInert;
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.body.style.paddingRight = previousPadding;
|
||||
previousFocus?.focus({ preventScroll: true });
|
||||
};
|
||||
}, [scheduleControlsHide]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Tab") {
|
||||
const controlsHidden = rootRef.current?.dataset.controlsVisible === "false";
|
||||
const focusable = [...(rootRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])',
|
||||
) ?? [])].filter((element) =>
|
||||
element.offsetParent !== null &&
|
||||
(!controlsHidden || element.classList.contains("viewer-essential"))
|
||||
);
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1)!;
|
||||
if (document.activeElement === rootRef.current) {
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? last : first).focus();
|
||||
} else if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
||||
switch (event.key) {
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
requestClose();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
event.preventDefault();
|
||||
engine.navigate(-1);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
event.preventDefault();
|
||||
engine.navigate(1);
|
||||
break;
|
||||
case "+":
|
||||
case "=":
|
||||
event.preventDefault();
|
||||
engine.zoomBy(1.4);
|
||||
break;
|
||||
case "-":
|
||||
event.preventDefault();
|
||||
engine.zoomBy(1 / 1.4);
|
||||
break;
|
||||
case "0":
|
||||
event.preventDefault();
|
||||
engine.resetZoom();
|
||||
break;
|
||||
case "f":
|
||||
case "F":
|
||||
event.preventDefault();
|
||||
void toggleFullscreen();
|
||||
break;
|
||||
case " ":
|
||||
if ((event.target as HTMLElement | null)?.closest("button, a, input, select, textarea")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
commitSingleTap();
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [commitSingleTap, engine, requestClose, toggleFullscreen]);
|
||||
|
||||
useEffect(() => () => {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
window.clearTimeout(controlsTimerRef.current);
|
||||
window.clearTimeout(tapTimerRef.current);
|
||||
window.clearTimeout(pendingTimerRef.current);
|
||||
window.clearTimeout(pendingRetryRef.current);
|
||||
}, []);
|
||||
|
||||
const originalUrl = sourceAttempt
|
||||
? `${image.originalUrl}?retry=${sourceAttempt}`
|
||||
: image.originalUrl;
|
||||
const frameStyle = {
|
||||
width: frameSize.width,
|
||||
height: frameSize.height,
|
||||
} satisfies CSSProperties;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`image-viewer ${closing ? "is-closing" : ""}`}
|
||||
data-controls-visible={controlsVisible}
|
||||
data-interacting={engine.interacting}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="图片查看器"
|
||||
tabIndex={-1}
|
||||
onFocusCapture={revealControls}
|
||||
onPointerMove={(event) => {
|
||||
if (event.pointerType === "mouse" && event.buttons === 0) revealControls();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={engine.canvasRef}
|
||||
className="viewer-canvas"
|
||||
aria-label={`${image.name},第 ${index + 1} 张,共 ${total} 张`}
|
||||
{...canvasHandlers}
|
||||
>
|
||||
<div ref={engine.trackRef} className="viewer-track">
|
||||
{previous && (
|
||||
<NeighborSlide
|
||||
image={previous}
|
||||
viewport={viewport}
|
||||
offset={-slideSpan}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="viewer-slide viewer-slide-current">
|
||||
<div className="viewer-media" style={frameStyle}>
|
||||
<div
|
||||
ref={engine.motionRef}
|
||||
className="viewer-transform"
|
||||
data-zoomed="false"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="viewer-image-frame">
|
||||
<img
|
||||
className="viewer-image viewer-image-thumbnail"
|
||||
src={image.thumbnailUrl}
|
||||
alt=""
|
||||
data-image-id={image.id}
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
loading="eager"
|
||||
fetchPriority="high"
|
||||
onLoad={handleThumbnailLoad}
|
||||
/>
|
||||
<img
|
||||
key={`${image.id}-preview`}
|
||||
className={`viewer-image viewer-image-preview ${previewReady && !originalReady ? "is-ready" : ""}`}
|
||||
src={image.previewUrl}
|
||||
alt=""
|
||||
data-image-id={image.id}
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
fetchPriority="high"
|
||||
onLoad={handlePreviewLoad}
|
||||
onError={handlePreviewError}
|
||||
/>
|
||||
{originalEnabled && (
|
||||
<img
|
||||
key={`${image.id}-${sourceAttempt}`}
|
||||
className={`viewer-image viewer-image-original ${originalReady ? "is-ready" : ""}`}
|
||||
src={originalUrl}
|
||||
alt={image.name}
|
||||
data-image-id={image.id}
|
||||
data-source-attempt={sourceAttempt}
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
loading="eager"
|
||||
fetchPriority="auto"
|
||||
onLoad={handleOriginalLoad}
|
||||
onError={handleOriginalError}
|
||||
/>
|
||||
)}
|
||||
{!thumbnailReady && !previewReady && !originalReady && !originalFailed && (
|
||||
<span className="viewer-loading" aria-label="正在载入原图">
|
||||
<LoaderCircle className="spin" size={22} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{next && (
|
||||
<NeighborSlide
|
||||
image={next}
|
||||
viewport={viewport}
|
||||
offset={slideSpan}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header
|
||||
className="viewer-topbar viewer-chrome"
|
||||
onPointerEnter={() => window.clearTimeout(controlsTimerRef.current)}
|
||||
onPointerLeave={scheduleControlsHide}
|
||||
onFocusCapture={() => window.clearTimeout(controlsTimerRef.current)}
|
||||
onBlurCapture={scheduleControlsHide}
|
||||
>
|
||||
<div className="viewer-about">
|
||||
<span className="viewer-counter">
|
||||
{formatCount(index + 1)} <i>/</i> {formatCount(total)}
|
||||
</span>
|
||||
<span className="viewer-file-copy">
|
||||
<strong title={image.name}>{image.name}</strong>
|
||||
<small>
|
||||
{formatCount(image.width)} x {formatCount(image.height)} · {formatBytes(image.size)}
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ViewerButton
|
||||
label="关闭查看器"
|
||||
className="viewer-close viewer-essential"
|
||||
onClick={requestClose}
|
||||
>
|
||||
<X size={22} />
|
||||
</ViewerButton>
|
||||
|
||||
{!compact && (
|
||||
<>
|
||||
<ViewerButton
|
||||
label="上一张"
|
||||
className="viewer-nav viewer-nav-previous viewer-chrome"
|
||||
onClick={() => engine.navigate(-1)}
|
||||
disabled={!previous}
|
||||
>
|
||||
<ChevronLeft size={28} />
|
||||
</ViewerButton>
|
||||
<ViewerButton
|
||||
label={pendingNext ? "正在载入下一张" : "下一张"}
|
||||
className="viewer-nav viewer-nav-next viewer-chrome"
|
||||
onClick={() => engine.navigate(1)}
|
||||
disabled={index + 1 >= total}
|
||||
>
|
||||
{pendingNext ? <LoaderCircle className="spin" size={21} /> : <ChevronRight size={28} />}
|
||||
</ViewerButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="viewer-dock viewer-chrome"
|
||||
onPointerEnter={() => window.clearTimeout(controlsTimerRef.current)}
|
||||
onPointerLeave={scheduleControlsHide}
|
||||
onFocusCapture={() => window.clearTimeout(controlsTimerRef.current)}
|
||||
onBlurCapture={scheduleControlsHide}
|
||||
>
|
||||
{!compact && (
|
||||
<>
|
||||
<ViewerButton label="缩小" onClick={() => engine.zoomBy(1 / 1.4)} disabled={!engine.canZoom}>
|
||||
<Minus size={19} />
|
||||
</ViewerButton>
|
||||
<output className="viewer-zoom-value" aria-live="off">
|
||||
{engine.settledScale <= 1.01 ? "适应" : `${Math.round(engine.settledScale * 100)}%`}
|
||||
</output>
|
||||
<ViewerButton label="放大" onClick={() => engine.zoomBy(1.4)} disabled={!engine.canZoom}>
|
||||
<Plus size={19} />
|
||||
</ViewerButton>
|
||||
</>
|
||||
)}
|
||||
<ViewerButton label="适应窗口" onClick={engine.resetZoom} disabled={engine.settledScale <= 1.01}>
|
||||
<Scan size={19} />
|
||||
</ViewerButton>
|
||||
<span className="viewer-dock-divider" aria-hidden="true" />
|
||||
<a
|
||||
className="viewer-icon-button"
|
||||
href={image.originalUrl}
|
||||
download={image.name}
|
||||
aria-label="下载原图"
|
||||
title="下载原图"
|
||||
>
|
||||
<Download size={19} />
|
||||
</a>
|
||||
{!compact && (
|
||||
<ViewerButton
|
||||
label={fullscreen ? "退出全屏" : "进入全屏"}
|
||||
onClick={() => void toggleFullscreen()}
|
||||
>
|
||||
{fullscreen ? <Minimize size={19} /> : <Maximize size={19} />}
|
||||
</ViewerButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{originalFailed && (
|
||||
<div className="viewer-source-error viewer-chrome" role="status">
|
||||
<ImageOff size={17} />
|
||||
<span>原图载入失败</span>
|
||||
<button type="button" onClick={retryOriginal}>
|
||||
<RefreshCw size={15} />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="sr-only" aria-live="polite">{announcement}</p>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function NeighborSlide({
|
||||
image,
|
||||
viewport,
|
||||
offset,
|
||||
}: {
|
||||
image: GalleryImage;
|
||||
viewport: ViewportSize;
|
||||
offset: number;
|
||||
}) {
|
||||
const size = fittedSize(image, viewport);
|
||||
return (
|
||||
<div
|
||||
className="viewer-slide viewer-slide-neighbor"
|
||||
style={{ transform: `translate3d(${offset}px, 0, 0)` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="viewer-neighbor-frame" style={{ width: size.width, height: size.height }}>
|
||||
<img
|
||||
src={image.thumbnailUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
fetchPriority="low"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewerButton({
|
||||
label,
|
||||
onClick,
|
||||
className = "",
|
||||
disabled = false,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`viewer-icon-button ${className}`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
type ToolbarPopoverProps = {
|
||||
children: ReactNode;
|
||||
closeLabel: string;
|
||||
icon: ReactNode;
|
||||
id: string;
|
||||
open: boolean;
|
||||
openLabel: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
panelClassName?: string;
|
||||
panelLabel: string;
|
||||
rootClassName?: string;
|
||||
triggerClassName?: string;
|
||||
};
|
||||
|
||||
export function ToolbarPopover({
|
||||
children,
|
||||
closeLabel,
|
||||
icon,
|
||||
id,
|
||||
open,
|
||||
openLabel,
|
||||
onOpenChange,
|
||||
panelClassName = "",
|
||||
panelLabel,
|
||||
rootClassName = "",
|
||||
triggerClassName = "",
|
||||
}: ToolbarPopoverProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const closeFromOutside = (event: PointerEvent) => {
|
||||
const root = rootRef.current;
|
||||
if (event.target instanceof Node && root?.contains(event.target)) return;
|
||||
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement instanceof HTMLElement && root?.contains(activeElement)) {
|
||||
activeElement.blur();
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
const closeFromKeyboard = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onOpenChange(false);
|
||||
triggerRef.current?.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", closeFromOutside, true);
|
||||
document.addEventListener("keydown", closeFromKeyboard);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", closeFromOutside, true);
|
||||
document.removeEventListener("keydown", closeFromKeyboard);
|
||||
};
|
||||
}, [onOpenChange, open]);
|
||||
|
||||
const triggerLabel = open ? closeLabel : openLabel;
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`toolbar-popover ${open ? "is-open" : ""} ${rootClassName}`}
|
||||
data-popover-open={open}
|
||||
>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`icon-button toolbar-popover-trigger ${triggerClassName}`}
|
||||
onClick={() => onOpenChange(!open)}
|
||||
aria-label={triggerLabel}
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
title={triggerLabel}
|
||||
>
|
||||
<span className="toolbar-popover-trigger-icon" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
id={id}
|
||||
className={`toolbar-popover-panel ${panelClassName}`}
|
||||
role="group"
|
||||
aria-label={panelLabel}
|
||||
aria-hidden={!open}
|
||||
inert={!open}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+42
-5
@@ -5,12 +5,48 @@ import type {
|
||||
ThumbnailStatus,
|
||||
} from "./types";
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
async function getJson<T>(url: string, signal?: AbortSignal): Promise<T> {
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`请求失败 (${response.status})`);
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const abortFromCaller = () => controller.abort(signal?.reason);
|
||||
if (signal?.aborted) {
|
||||
abortFromCaller();
|
||||
} else {
|
||||
signal?.addEventListener("abort", abortFromCaller, { once: true });
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
cache: "no-store",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`请求失败 (${response.status})`);
|
||||
}
|
||||
if (!response.headers.get("content-type")?.includes("application/json")) {
|
||||
throw new Error("服务器返回了无效数据");
|
||||
}
|
||||
try {
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) throw error;
|
||||
throw new Error("服务器返回了无效数据", { cause: error });
|
||||
}
|
||||
} catch (error) {
|
||||
if (timedOut) throw new Error("请求超时,请稍后重试", { cause: error });
|
||||
throw error;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
signal?.removeEventListener("abort", abortFromCaller);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function getGallery(signal?: AbortSignal): Promise<GallerySummary> {
|
||||
@@ -26,6 +62,7 @@ export function getImages(
|
||||
album: string;
|
||||
search: string;
|
||||
sort: SortMode;
|
||||
seed: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
},
|
||||
@@ -38,6 +75,6 @@ export function getImages(
|
||||
});
|
||||
if (options.album) params.set("album", options.album);
|
||||
if (options.search) params.set("search", options.search);
|
||||
if (options.seed) params.set("seed", options.seed);
|
||||
return getJson<ImagesPage>(`/api/images?${params}`, signal);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("Missing #root application container");
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
+673
-703
File diff suppressed because it is too large.
Load diff
@@ -7,6 +7,7 @@ export interface Album {
|
||||
export interface GallerySummary {
|
||||
total: number;
|
||||
albums: Album[];
|
||||
revision: string;
|
||||
}
|
||||
|
||||
export interface GalleryImage {
|
||||
@@ -19,8 +20,6 @@ export interface GalleryImage {
|
||||
size: number;
|
||||
modifiedMs: number;
|
||||
thumbnailUrl: string;
|
||||
previewUrl: string;
|
||||
originalUrl: string;
|
||||
}
|
||||
|
||||
export interface ImagesPage {
|
||||
@@ -41,4 +40,4 @@ export interface ThumbnailStatus {
|
||||
backgroundComplete: boolean;
|
||||
}
|
||||
|
||||
export type SortMode = "name-asc" | "name-desc" | "newest";
|
||||
export type SortMode = "name-asc" | "name-desc" | "newest" | "explore";
|
||||
@@ -1,826 +0,0 @@
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface Motion extends Point {
|
||||
scale: number;
|
||||
}
|
||||
|
||||
interface CanvasGeometry extends Point, Size {}
|
||||
|
||||
type VisualUpdate =
|
||||
| { kind: "motion"; motion: Motion }
|
||||
| { kind: "track"; motion: Motion };
|
||||
|
||||
type GestureMode = "idle" | "pending" | "navigation" | "dismiss" | "pan" | "pinch";
|
||||
|
||||
interface Gesture {
|
||||
mode: GestureMode;
|
||||
pointerId: number;
|
||||
pointerType: string;
|
||||
start: Point;
|
||||
last: Point;
|
||||
lastTime: number;
|
||||
velocity: Point;
|
||||
origin: Motion;
|
||||
pinchDistance: number;
|
||||
pinchMidpoint: Point;
|
||||
canvasCenter: Point;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
interface EngineOptions {
|
||||
imageId: string;
|
||||
frameSize: Size;
|
||||
maximumZoom: number;
|
||||
canPrevious: boolean;
|
||||
canNext: boolean;
|
||||
onNavigate: (direction: -1 | 1) => void;
|
||||
onEdge: (direction: -1 | 1) => void;
|
||||
onDismiss: () => void;
|
||||
onTap: (point: Point, pointerType: string) => void;
|
||||
onDragStart: () => void;
|
||||
onInteraction: () => void;
|
||||
}
|
||||
|
||||
interface AnimateOptions {
|
||||
duration?: number;
|
||||
commitRaster?: boolean;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
const EMPTY_GESTURE: Gesture = {
|
||||
mode: "idle",
|
||||
pointerId: -1,
|
||||
pointerType: "mouse",
|
||||
start: { x: 0, y: 0 },
|
||||
last: { x: 0, y: 0 },
|
||||
lastTime: 0,
|
||||
velocity: { x: 0, y: 0 },
|
||||
origin: { x: 0, y: 0, scale: 1 },
|
||||
pinchDistance: 0,
|
||||
pinchMidpoint: { x: 0, y: 0 },
|
||||
canvasCenter: { x: 0, y: 0 },
|
||||
moved: false,
|
||||
};
|
||||
|
||||
const RASTER_PIXEL_BUDGET = 40_000_000;
|
||||
const RASTER_EDGE_LIMIT = 8192;
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
function magnitude(point: Point): number {
|
||||
return Math.hypot(point.x, point.y);
|
||||
}
|
||||
|
||||
function distance(first: Point, second: Point): number {
|
||||
return Math.hypot(first.x - second.x, first.y - second.y);
|
||||
}
|
||||
|
||||
function midpoint(first: Point, second: Point): Point {
|
||||
return { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 };
|
||||
}
|
||||
|
||||
function resist(value: number, limit: number, factor = 0.2): number {
|
||||
if (value > limit) return limit + (value - limit) * factor;
|
||||
if (value < -limit) return -limit + (value + limit) * factor;
|
||||
return value;
|
||||
}
|
||||
|
||||
function easeOutQuart(value: number): number {
|
||||
return 1 - (1 - value) ** 4;
|
||||
}
|
||||
|
||||
function reducedMotion(): boolean {
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
export function useViewerEngine(options: EngineOptions) {
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const motionRef = useRef<HTMLDivElement>(null);
|
||||
const animationRef = useRef(0);
|
||||
const trackAnimationRef = useRef(0);
|
||||
const inputFrameRef = useRef(0);
|
||||
const navigatingRef = useRef(false);
|
||||
const navigationUnlockRef = useRef(0);
|
||||
const rasterTimerRef = useRef(0);
|
||||
const pointersRef = useRef(new Map<number, Point>());
|
||||
const gestureRef = useRef<Gesture>({ ...EMPTY_GESTURE });
|
||||
const layoutImageRef = useRef(options.imageId);
|
||||
const currentRef = useRef<Motion>({ x: 0, y: 0, scale: 1 });
|
||||
const targetRef = useRef<Motion>({ x: 0, y: 0, scale: 1 });
|
||||
const trackMotionRef = useRef<Motion>({ x: 0, y: 0, scale: 1 });
|
||||
const queuedVisualRef = useRef<VisualUpdate | null>(null);
|
||||
const geometryRef = useRef<CanvasGeometry>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
const rasterScaleRef = useRef(1);
|
||||
const [settledScale, setSettledScale] = useState(1);
|
||||
const [interacting, setInteracting] = useState(false);
|
||||
|
||||
const captureGeometry = useCallback(() => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
geometryRef.current = {
|
||||
x: rect?.left ?? 0,
|
||||
y: rect?.top ?? 0,
|
||||
width: rect?.width ?? window.innerWidth,
|
||||
height: rect?.height ?? window.innerHeight,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const pageSpan = useCallback(() => {
|
||||
const width = geometryRef.current.width;
|
||||
return width + (width <= 720 ? 12 : 28);
|
||||
}, []);
|
||||
|
||||
const boundsFor = useCallback((scale: number) => {
|
||||
const { frameSize } = optionsRef.current;
|
||||
const { width, height } = geometryRef.current;
|
||||
return {
|
||||
x: Math.max(0, (frameSize.width * scale - width) / 2),
|
||||
y: Math.max(0, (frameSize.height * scale - height) / 2),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const constrain = useCallback((motion: Motion): Motion => {
|
||||
const maximumZoom = Math.max(1, optionsRef.current.maximumZoom);
|
||||
const scale = clamp(motion.scale, 1, maximumZoom);
|
||||
const bounds = boundsFor(scale);
|
||||
return {
|
||||
x: clamp(motion.x, -bounds.x, bounds.x),
|
||||
y: clamp(motion.y, -bounds.y, bounds.y),
|
||||
scale,
|
||||
};
|
||||
}, [boundsFor]);
|
||||
|
||||
const applyMotion = useCallback((motion: Motion) => {
|
||||
const element = motionRef.current;
|
||||
if (!element) return;
|
||||
const rasterScale = rasterScaleRef.current;
|
||||
element.style.transform = `translate3d(${motion.x.toFixed(2)}px, ${motion.y.toFixed(2)}px, 0) scale(${(motion.scale / rasterScale).toFixed(4)})`;
|
||||
element.dataset.zoomed = String(motion.scale > 1.01);
|
||||
}, []);
|
||||
|
||||
const commitRaster = useCallback((
|
||||
motion = currentRef.current,
|
||||
settle = true,
|
||||
displayedMotion = motion,
|
||||
) => {
|
||||
const element = motionRef.current;
|
||||
if (!element) return;
|
||||
const { frameSize } = optionsRef.current;
|
||||
const density = clamp(window.devicePixelRatio || 1, 1, 3);
|
||||
const areaScale = Math.sqrt(
|
||||
RASTER_PIXEL_BUDGET /
|
||||
Math.max(frameSize.width * frameSize.height * density * density, 1),
|
||||
);
|
||||
const edgeScale = Math.min(
|
||||
RASTER_EDGE_LIMIT / Math.max(frameSize.width * density, 1),
|
||||
RASTER_EDGE_LIMIT / Math.max(frameSize.height * density, 1),
|
||||
);
|
||||
const maximumRasterScale = Math.max(1, Math.min(areaScale, edgeScale));
|
||||
const requestedRasterScale = motion.scale <= 1.01
|
||||
? 1
|
||||
: Math.max(1, Math.min(motion.scale, maximumRasterScale));
|
||||
// Keep an already-promoted backing layer while zoomed. Dropping it on
|
||||
// every zoom-out makes the browser resample a smaller bitmap and can
|
||||
// soften the next quick pinch. The fit state still releases it.
|
||||
const rasterScale = motion.scale <= 1.01
|
||||
? 1
|
||||
: Math.max(
|
||||
requestedRasterScale,
|
||||
Math.min(rasterScaleRef.current, maximumRasterScale),
|
||||
);
|
||||
rasterScaleRef.current = rasterScale;
|
||||
const snap = (value: number) => Math.round(value * density) / density;
|
||||
const width = snap(frameSize.width * rasterScale);
|
||||
const height = snap(frameSize.height * rasterScale);
|
||||
const left = snap((frameSize.width - width) / 2);
|
||||
const top = snap((frameSize.height - height) / 2);
|
||||
element.style.width = `${width.toFixed(2)}px`;
|
||||
element.style.height = `${height.toFixed(2)}px`;
|
||||
element.style.left = `${left.toFixed(2)}px`;
|
||||
element.style.top = `${top.toFixed(2)}px`;
|
||||
element.style.setProperty("--viewer-will-change", settle ? "auto" : "transform");
|
||||
element.dataset.scale = motion.scale.toFixed(4);
|
||||
element.dataset.rasterScale = rasterScale.toFixed(4);
|
||||
// A preheat can happen before an animation reaches its destination. Keep
|
||||
// the currently displayed transform while changing the backing layer so
|
||||
// promotion itself never causes a visible jump.
|
||||
applyMotion(displayedMotion);
|
||||
if (settle) setSettledScale(motion.scale);
|
||||
}, [applyMotion]);
|
||||
|
||||
// Keep a little headroom in the backing layer while the user is zooming.
|
||||
// Rebuilding on every pointer event is expensive, so only promote it after
|
||||
// a meaningful scale increase. Zooming out can keep the larger layer and
|
||||
// therefore remains sharp without another allocation.
|
||||
const prepareRaster = useCallback((motion: Motion) => {
|
||||
const scale = Math.max(1, motion.scale);
|
||||
const currentRaster = rasterScaleRef.current;
|
||||
if (scale <= currentRaster * 1.25) return;
|
||||
commitRaster(constrain(motion), false, currentRef.current);
|
||||
}, [commitRaster, constrain]);
|
||||
|
||||
const scheduleRaster = useCallback((delay = 100) => {
|
||||
window.clearTimeout(rasterTimerRef.current);
|
||||
rasterTimerRef.current = window.setTimeout(() => commitRaster(), delay);
|
||||
}, [commitRaster]);
|
||||
|
||||
const stopImageAnimation = useCallback(() => {
|
||||
window.cancelAnimationFrame(animationRef.current);
|
||||
animationRef.current = 0;
|
||||
motionRef.current?.style.setProperty("--viewer-will-change", "auto");
|
||||
}, []);
|
||||
|
||||
const setMotion = useCallback((motion: Motion) => {
|
||||
currentRef.current = motion;
|
||||
targetRef.current = motion;
|
||||
applyMotion(motion);
|
||||
}, [applyMotion]);
|
||||
|
||||
const animateMotion = useCallback((destination: Motion, animation: AnimateOptions = {}) => {
|
||||
stopImageAnimation();
|
||||
window.clearTimeout(rasterTimerRef.current);
|
||||
const target = constrain(destination);
|
||||
prepareRaster(target);
|
||||
targetRef.current = target;
|
||||
const duration = reducedMotion() ? 0 : (animation.duration ?? 240);
|
||||
if (!duration) {
|
||||
setMotion(target);
|
||||
if (animation.commitRaster !== false) commitRaster(target);
|
||||
animation.onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
motionRef.current?.style.setProperty("--viewer-will-change", "transform");
|
||||
const start = currentRef.current;
|
||||
const startedAt = performance.now();
|
||||
const tick = (time: number) => {
|
||||
const progress = clamp((time - startedAt) / duration, 0, 1);
|
||||
const eased = easeOutQuart(progress);
|
||||
const next = {
|
||||
x: start.x + (target.x - start.x) * eased,
|
||||
y: start.y + (target.y - start.y) * eased,
|
||||
scale: start.scale + (target.scale - start.scale) * eased,
|
||||
};
|
||||
currentRef.current = next;
|
||||
applyMotion(next);
|
||||
if (progress < 1) {
|
||||
animationRef.current = window.requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
animationRef.current = 0;
|
||||
currentRef.current = target;
|
||||
applyMotion(target);
|
||||
if (animation.commitRaster !== false) commitRaster(target);
|
||||
animation.onComplete?.();
|
||||
};
|
||||
animationRef.current = window.requestAnimationFrame(tick);
|
||||
}, [applyMotion, commitRaster, constrain, prepareRaster, setMotion, stopImageAnimation]);
|
||||
|
||||
const applyTrack = useCallback((x: number, y = 0, scale = 1) => {
|
||||
const canvas = canvasRef.current;
|
||||
const progress = clamp(
|
||||
Math.max(0, y) / Math.max(geometryRef.current.height * 0.7, 1),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
trackMotionRef.current = { x, y, scale };
|
||||
const track = trackRef.current;
|
||||
if (track) {
|
||||
track.style.transform = `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0) scale(${scale.toFixed(4)})`;
|
||||
}
|
||||
if (canvas) {
|
||||
canvas.style.setProperty("--viewer-dismiss-progress", progress.toFixed(4));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const flushVisual = useCallback(() => {
|
||||
window.cancelAnimationFrame(inputFrameRef.current);
|
||||
inputFrameRef.current = 0;
|
||||
const update = queuedVisualRef.current;
|
||||
queuedVisualRef.current = null;
|
||||
if (!update) return;
|
||||
if (update.kind === "motion") applyMotion(update.motion);
|
||||
else applyTrack(update.motion.x, update.motion.y, update.motion.scale);
|
||||
}, [applyMotion, applyTrack]);
|
||||
|
||||
const queueVisual = useCallback((update: VisualUpdate) => {
|
||||
queuedVisualRef.current = update;
|
||||
if (inputFrameRef.current) return;
|
||||
inputFrameRef.current = window.requestAnimationFrame(() => {
|
||||
inputFrameRef.current = 0;
|
||||
const pending = queuedVisualRef.current;
|
||||
queuedVisualRef.current = null;
|
||||
if (!pending) return;
|
||||
if (pending.kind === "motion") applyMotion(pending.motion);
|
||||
else applyTrack(pending.motion.x, pending.motion.y, pending.motion.scale);
|
||||
});
|
||||
}, [applyMotion, applyTrack]);
|
||||
|
||||
const stopTrackAnimation = useCallback(() => {
|
||||
window.cancelAnimationFrame(trackAnimationRef.current);
|
||||
trackAnimationRef.current = 0;
|
||||
trackRef.current?.style.setProperty("will-change", "auto");
|
||||
}, []);
|
||||
|
||||
const animateTrack = useCallback((
|
||||
from: Point & { scale: number },
|
||||
to: Point & { scale: number },
|
||||
duration: number,
|
||||
onComplete?: () => void,
|
||||
) => {
|
||||
stopTrackAnimation();
|
||||
trackRef.current?.style.setProperty("will-change", "transform");
|
||||
const actualDuration = reducedMotion() ? 0 : duration;
|
||||
if (!actualDuration) {
|
||||
applyTrack(to.x, to.y, to.scale);
|
||||
trackRef.current?.style.setProperty("will-change", "auto");
|
||||
onComplete?.();
|
||||
return;
|
||||
}
|
||||
const startedAt = performance.now();
|
||||
const tick = (time: number) => {
|
||||
const progress = clamp((time - startedAt) / actualDuration, 0, 1);
|
||||
const eased = easeOutQuart(progress);
|
||||
applyTrack(
|
||||
from.x + (to.x - from.x) * eased,
|
||||
from.y + (to.y - from.y) * eased,
|
||||
from.scale + (to.scale - from.scale) * eased,
|
||||
);
|
||||
if (progress < 1) {
|
||||
trackAnimationRef.current = window.requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
trackAnimationRef.current = 0;
|
||||
trackRef.current?.style.setProperty("will-change", "auto");
|
||||
onComplete?.();
|
||||
};
|
||||
trackAnimationRef.current = window.requestAnimationFrame(tick);
|
||||
}, [applyTrack, stopTrackAnimation]);
|
||||
|
||||
const zoomAround = useCallback((base: Motion, scale: number, point: Point): Motion => {
|
||||
const geometry = geometryRef.current;
|
||||
const center = {
|
||||
x: geometry.x + geometry.width / 2,
|
||||
y: geometry.y + geometry.height / 2,
|
||||
};
|
||||
const ratio = scale / Math.max(base.scale, 0.001);
|
||||
return constrain({
|
||||
x: point.x - center.x - (point.x - center.x - base.x) * ratio,
|
||||
y: point.y - center.y - (point.y - center.y - base.y) * ratio,
|
||||
scale,
|
||||
});
|
||||
}, [constrain]);
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
optionsRef.current.onInteraction();
|
||||
animateMotion({ x: 0, y: 0, scale: 1 }, { duration: 180 });
|
||||
}, [animateMotion]);
|
||||
|
||||
const zoomTo = useCallback((scale: number, point?: Point) => {
|
||||
const maximum = Math.max(1, optionsRef.current.maximumZoom);
|
||||
const nextScale = clamp(scale, 1, maximum);
|
||||
const geometry = geometryRef.current;
|
||||
const anchor = point ?? {
|
||||
x: geometry.x + geometry.width / 2,
|
||||
y: geometry.y + geometry.height / 2,
|
||||
};
|
||||
optionsRef.current.onInteraction();
|
||||
animateMotion(zoomAround(currentRef.current, nextScale, anchor), { duration: 180 });
|
||||
}, [animateMotion, zoomAround]);
|
||||
|
||||
const zoomBy = useCallback((factor: number) => {
|
||||
zoomTo(currentRef.current.scale * factor);
|
||||
}, [zoomTo]);
|
||||
|
||||
const toggleZoomAt = useCallback((point: Point) => {
|
||||
if (currentRef.current.scale > 1.08) {
|
||||
resetZoom();
|
||||
return;
|
||||
}
|
||||
zoomTo(Math.min(4, Math.max(1, optionsRef.current.maximumZoom)), point);
|
||||
}, [resetZoom, zoomTo]);
|
||||
|
||||
const completeNavigation = useCallback((direction: -1 | 1) => {
|
||||
const sourceId = optionsRef.current.imageId;
|
||||
optionsRef.current.onNavigate(direction);
|
||||
window.clearTimeout(navigationUnlockRef.current);
|
||||
navigationUnlockRef.current = window.setTimeout(() => {
|
||||
navigatingRef.current = false;
|
||||
if (optionsRef.current.imageId === sourceId) applyTrack(0, 0, 1);
|
||||
}, 500);
|
||||
}, [applyTrack]);
|
||||
|
||||
const navigate = useCallback((direction: -1 | 1) => {
|
||||
if (navigatingRef.current) return false;
|
||||
const available = direction < 0 ? optionsRef.current.canPrevious : optionsRef.current.canNext;
|
||||
if (!available) {
|
||||
optionsRef.current.onEdge(direction);
|
||||
return false;
|
||||
}
|
||||
optionsRef.current.onDragStart();
|
||||
flushVisual();
|
||||
navigatingRef.current = true;
|
||||
const slide = () => {
|
||||
const destination = -direction * pageSpan();
|
||||
animateTrack(
|
||||
trackMotionRef.current,
|
||||
{ x: destination, y: 0, scale: 1 },
|
||||
260,
|
||||
() => completeNavigation(direction),
|
||||
);
|
||||
};
|
||||
if (currentRef.current.scale > 1.01) {
|
||||
animateMotion(
|
||||
{ x: 0, y: 0, scale: 1 },
|
||||
{ duration: 160, onComplete: slide },
|
||||
);
|
||||
} else {
|
||||
stopImageAnimation();
|
||||
slide();
|
||||
}
|
||||
return true;
|
||||
}, [animateMotion, animateTrack, completeNavigation, flushVisual, pageSpan, stopImageAnimation]);
|
||||
|
||||
const beginPinch = useCallback(() => {
|
||||
const pointers = [...pointersRef.current.values()];
|
||||
if (pointers.length < 2) return;
|
||||
const first = pointers[0];
|
||||
const second = pointers[1];
|
||||
const geometry = geometryRef.current;
|
||||
flushVisual();
|
||||
applyTrack(0, 0, 1);
|
||||
optionsRef.current.onDragStart();
|
||||
gestureRef.current = {
|
||||
...EMPTY_GESTURE,
|
||||
mode: "pinch",
|
||||
origin: currentRef.current,
|
||||
pinchDistance: Math.max(1, distance(first, second)),
|
||||
pinchMidpoint: midpoint(first, second),
|
||||
canvasCenter: {
|
||||
x: geometry.x + geometry.width / 2,
|
||||
y: geometry.y + geometry.height / 2,
|
||||
},
|
||||
moved: true,
|
||||
};
|
||||
setInteracting(true);
|
||||
}, [applyTrack, flushVisual]);
|
||||
|
||||
const handlePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||
if (navigatingRef.current) return;
|
||||
event.preventDefault();
|
||||
captureGeometry();
|
||||
flushVisual();
|
||||
stopImageAnimation();
|
||||
stopTrackAnimation();
|
||||
window.clearTimeout(rasterTimerRef.current);
|
||||
targetRef.current = currentRef.current;
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Synthetic events and older WebViews may not expose pointer capture.
|
||||
}
|
||||
const point = { x: event.clientX, y: event.clientY };
|
||||
pointersRef.current.set(event.pointerId, { ...point });
|
||||
optionsRef.current.onInteraction();
|
||||
|
||||
if (pointersRef.current.size >= 2) {
|
||||
beginPinch();
|
||||
return;
|
||||
}
|
||||
gestureRef.current = {
|
||||
...EMPTY_GESTURE,
|
||||
mode: "pending",
|
||||
pointerId: event.pointerId,
|
||||
pointerType: event.pointerType,
|
||||
start: { ...point },
|
||||
last: { ...point },
|
||||
lastTime: performance.now(),
|
||||
origin: currentRef.current,
|
||||
};
|
||||
}, [beginPinch, captureGeometry, flushVisual, stopImageAnimation, stopTrackAnimation]);
|
||||
|
||||
const handlePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const tracked = pointersRef.current.get(event.pointerId);
|
||||
if (!tracked) return;
|
||||
event.preventDefault();
|
||||
pointersRef.current.set(event.pointerId, {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
const gesture = gestureRef.current;
|
||||
|
||||
if (gesture.mode === "pinch") {
|
||||
const pointers = [...pointersRef.current.values()];
|
||||
if (pointers.length < 2) return;
|
||||
const first = pointers[0];
|
||||
const second = pointers[1];
|
||||
const midpointNow = midpoint(first, second);
|
||||
const rawScale = gesture.origin.scale *
|
||||
(distance(first, second) / Math.max(gesture.pinchDistance, 1));
|
||||
const maximum = Math.max(1, optionsRef.current.maximumZoom);
|
||||
const scale = rawScale < 1
|
||||
? 1 - (1 - rawScale) * 0.22
|
||||
: rawScale > maximum
|
||||
? maximum + (rawScale - maximum) * 0.16
|
||||
: rawScale;
|
||||
const center = gesture.canvasCenter;
|
||||
const local = {
|
||||
x: (gesture.pinchMidpoint.x - center.x - gesture.origin.x) / gesture.origin.scale,
|
||||
y: (gesture.pinchMidpoint.y - center.y - gesture.origin.y) / gesture.origin.scale,
|
||||
};
|
||||
const next = {
|
||||
x: midpointNow.x - center.x - local.x * scale,
|
||||
y: midpointNow.y - center.y - local.y * scale,
|
||||
scale,
|
||||
};
|
||||
currentRef.current = next;
|
||||
targetRef.current = next;
|
||||
prepareRaster(next);
|
||||
motionRef.current?.style.setProperty("--viewer-will-change", "transform");
|
||||
queueVisual({ kind: "motion", motion: next });
|
||||
return;
|
||||
}
|
||||
|
||||
if (gesture.pointerId !== event.pointerId || gesture.mode === "idle") return;
|
||||
const point = { x: event.clientX, y: event.clientY };
|
||||
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
|
||||
const now = performance.now();
|
||||
const elapsed = Math.max(1, now - gesture.lastTime);
|
||||
gesture.velocity = {
|
||||
x: gesture.velocity.x * 0.62 + ((point.x - gesture.last.x) / elapsed) * 0.38,
|
||||
y: gesture.velocity.y * 0.62 + ((point.y - gesture.last.y) / elapsed) * 0.38,
|
||||
};
|
||||
gesture.last = point;
|
||||
gesture.lastTime = now;
|
||||
|
||||
if (gesture.mode === "pending" && magnitude(delta) >= (gesture.pointerType === "mouse" ? 5 : 9)) {
|
||||
gesture.moved = true;
|
||||
optionsRef.current.onDragStart();
|
||||
setInteracting(true);
|
||||
if (gesture.origin.scale > 1.01) gesture.mode = "pan";
|
||||
else if (Math.abs(delta.x) > Math.abs(delta.y) * 1.08) gesture.mode = "navigation";
|
||||
else if (gesture.pointerType === "mouse") gesture.mode = "idle";
|
||||
else gesture.mode = "dismiss";
|
||||
}
|
||||
|
||||
if (gesture.mode === "navigation") {
|
||||
let x = delta.x;
|
||||
if ((x > 0 && !optionsRef.current.canPrevious) || (x < 0 && !optionsRef.current.canNext)) {
|
||||
x *= 0.22;
|
||||
}
|
||||
queueVisual({ kind: "track", motion: { x, y: 0, scale: 1 } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (gesture.mode === "dismiss") {
|
||||
const y = delta.y >= 0 ? delta.y * 0.94 : delta.y * 0.16;
|
||||
const progress = clamp(Math.max(0, y) / Math.max(geometryRef.current.height * 0.7, 1), 0, 1);
|
||||
queueVisual({
|
||||
kind: "track",
|
||||
motion: { x: delta.x * 0.08, y, scale: 1 - progress * 0.055 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (gesture.mode === "pan") {
|
||||
const scale = gesture.origin.scale;
|
||||
const bounds = boundsFor(scale);
|
||||
const next = {
|
||||
x: resist(gesture.origin.x + delta.x, bounds.x),
|
||||
y: resist(gesture.origin.y + delta.y, bounds.y),
|
||||
scale,
|
||||
};
|
||||
currentRef.current = next;
|
||||
targetRef.current = next;
|
||||
motionRef.current?.style.setProperty("--viewer-will-change", "transform");
|
||||
queueVisual({ kind: "motion", motion: next });
|
||||
}
|
||||
}, [boundsFor, prepareRaster, queueVisual]);
|
||||
|
||||
const finishPointer = useCallback((event: ReactPointerEvent<HTMLDivElement>, cancelled: boolean) => {
|
||||
const gesture = gestureRef.current;
|
||||
const tracked = pointersRef.current.has(event.pointerId);
|
||||
pointersRef.current.delete(event.pointerId);
|
||||
if (!tracked) return;
|
||||
flushVisual();
|
||||
|
||||
if (gesture.mode === "pinch") {
|
||||
const remaining = [...pointersRef.current.entries()];
|
||||
if (remaining.length === 1) {
|
||||
window.clearTimeout(rasterTimerRef.current);
|
||||
const [pointerId, point] = remaining[0];
|
||||
gestureRef.current = {
|
||||
...EMPTY_GESTURE,
|
||||
mode: "pan",
|
||||
pointerId,
|
||||
pointerType: "touch",
|
||||
start: { ...point },
|
||||
last: { ...point },
|
||||
lastTime: performance.now(),
|
||||
origin: currentRef.current,
|
||||
moved: true,
|
||||
};
|
||||
return;
|
||||
}
|
||||
const target = constrain(currentRef.current);
|
||||
animateMotion(target, { duration: 220 });
|
||||
gestureRef.current = { ...EMPTY_GESTURE };
|
||||
setInteracting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (gesture.pointerId !== event.pointerId) return;
|
||||
const endPoint = cancelled
|
||||
? gesture.last
|
||||
: { x: event.clientX, y: event.clientY };
|
||||
const delta = {
|
||||
x: endPoint.x - gesture.start.x,
|
||||
y: endPoint.y - gesture.start.y,
|
||||
};
|
||||
const displayedTrack = trackMotionRef.current;
|
||||
let keepInteracting = false;
|
||||
if (cancelled && gesture.mode === "pan") {
|
||||
animateMotion(constrain(currentRef.current), { duration: 180 });
|
||||
} else if (cancelled) {
|
||||
animateTrack(displayedTrack, { x: 0, y: 0, scale: 1 }, 180);
|
||||
} else if (gesture.mode === "navigation") {
|
||||
const width = geometryRef.current.width;
|
||||
const direction: -1 | 1 = delta.x > 0 ? -1 : 1;
|
||||
const available = direction < 0 ? optionsRef.current.canPrevious : optionsRef.current.canNext;
|
||||
const commit = available &&
|
||||
(Math.abs(delta.x) > Math.min(120, width * 0.2) || Math.abs(gesture.velocity.x) > 0.55);
|
||||
if (commit) {
|
||||
navigatingRef.current = true;
|
||||
const destination = -direction * pageSpan();
|
||||
animateTrack(
|
||||
displayedTrack,
|
||||
{ x: destination, y: 0, scale: 1 },
|
||||
230,
|
||||
() => completeNavigation(direction),
|
||||
);
|
||||
} else {
|
||||
if (!available) optionsRef.current.onEdge(direction);
|
||||
animateTrack(displayedTrack, { x: 0, y: 0, scale: 1 }, 210);
|
||||
}
|
||||
} else if (gesture.mode === "dismiss") {
|
||||
const height = geometryRef.current.height;
|
||||
const commit = delta.y > Math.min(150, height * 0.18) ||
|
||||
(delta.y > 50 && gesture.velocity.y > 0.65);
|
||||
if (commit) {
|
||||
navigatingRef.current = true;
|
||||
keepInteracting = true;
|
||||
animateTrack(
|
||||
displayedTrack,
|
||||
{ x: delta.x * 0.12, y: height * 1.08, scale: 0.92 },
|
||||
240,
|
||||
optionsRef.current.onDismiss,
|
||||
);
|
||||
} else {
|
||||
animateTrack(
|
||||
displayedTrack,
|
||||
{ x: 0, y: 0, scale: 1 },
|
||||
220,
|
||||
);
|
||||
}
|
||||
} else if (gesture.mode === "pan") {
|
||||
const inertial = {
|
||||
x: currentRef.current.x + clamp(gesture.velocity.x * 130, -180, 180),
|
||||
y: currentRef.current.y + clamp(gesture.velocity.y * 130, -180, 180),
|
||||
scale: currentRef.current.scale,
|
||||
};
|
||||
animateMotion(constrain(inertial), { duration: 260 });
|
||||
} else if (gesture.mode === "pending" && !gesture.moved) {
|
||||
optionsRef.current.onTap(endPoint, gesture.pointerType);
|
||||
}
|
||||
|
||||
gestureRef.current = { ...EMPTY_GESTURE };
|
||||
if (!keepInteracting) setInteracting(false);
|
||||
}, [animateMotion, animateTrack, completeNavigation, constrain, flushVisual, pageSpan]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
if (optionsRef.current.maximumZoom <= 1.01) return;
|
||||
optionsRef.current.onInteraction();
|
||||
stopImageAnimation();
|
||||
const delta = event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
||||
? event.deltaY * 18
|
||||
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
|
||||
? event.deltaY * geometryRef.current.height
|
||||
: event.deltaY;
|
||||
targetRef.current = currentRef.current;
|
||||
const base = currentRef.current;
|
||||
const scale = clamp(
|
||||
base.scale * Math.exp(-delta * 0.00135),
|
||||
1,
|
||||
Math.max(1, optionsRef.current.maximumZoom),
|
||||
);
|
||||
const target = zoomAround(base, scale, { x: event.clientX, y: event.clientY });
|
||||
const motionReduced = reducedMotion();
|
||||
animateMotion(target, { duration: 90, commitRaster: motionReduced });
|
||||
if (!motionReduced) scheduleRaster(120);
|
||||
};
|
||||
canvas.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => canvas.removeEventListener("wheel", handleWheel);
|
||||
}, [animateMotion, scheduleRaster, stopImageAnimation, zoomAround]);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => captureGeometry();
|
||||
window.addEventListener("resize", update);
|
||||
window.visualViewport?.addEventListener("resize", update);
|
||||
return () => {
|
||||
window.removeEventListener("resize", update);
|
||||
window.visualViewport?.removeEventListener("resize", update);
|
||||
};
|
||||
}, [captureGeometry]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
captureGeometry();
|
||||
flushVisual();
|
||||
stopImageAnimation();
|
||||
stopTrackAnimation();
|
||||
window.clearTimeout(rasterTimerRef.current);
|
||||
window.clearTimeout(navigationUnlockRef.current);
|
||||
pointersRef.current.clear();
|
||||
gestureRef.current = { ...EMPTY_GESTURE };
|
||||
if (layoutImageRef.current !== options.imageId) {
|
||||
layoutImageRef.current = options.imageId;
|
||||
currentRef.current = { x: 0, y: 0, scale: 1 };
|
||||
targetRef.current = { x: 0, y: 0, scale: 1 };
|
||||
rasterScaleRef.current = 1;
|
||||
} else {
|
||||
const motion = constrain(currentRef.current);
|
||||
currentRef.current = motion;
|
||||
targetRef.current = motion;
|
||||
}
|
||||
navigatingRef.current = false;
|
||||
applyTrack(0, 0, 1);
|
||||
commitRaster(currentRef.current);
|
||||
setInteracting(false);
|
||||
}, [options.imageId, options.frameSize.height, options.frameSize.width, applyTrack, captureGeometry, commitRaster, constrain, flushVisual, stopImageAnimation, stopTrackAnimation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentRef.current.scale <= options.maximumZoom + 0.001) return;
|
||||
animateMotion(constrain(currentRef.current));
|
||||
}, [options.maximumZoom, animateMotion, constrain]);
|
||||
|
||||
useEffect(() => () => {
|
||||
window.cancelAnimationFrame(animationRef.current);
|
||||
window.cancelAnimationFrame(trackAnimationRef.current);
|
||||
window.cancelAnimationFrame(inputFrameRef.current);
|
||||
window.clearTimeout(rasterTimerRef.current);
|
||||
window.clearTimeout(navigationUnlockRef.current);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
canvasRef,
|
||||
trackRef,
|
||||
motionRef,
|
||||
settledScale,
|
||||
interacting,
|
||||
canZoom: options.maximumZoom > 1.01,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => finishPointer(event, false),
|
||||
handlePointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => finishPointer(event, true),
|
||||
handleLostPointerCapture: (event: ReactPointerEvent<HTMLDivElement>) => finishPointer(event, true),
|
||||
navigate,
|
||||
resetZoom,
|
||||
toggleZoomAt,
|
||||
zoomBy,
|
||||
};
|
||||
}
|
||||
|
||||
export type ViewerEngine = ReturnType<typeof useViewerEngine>;
|
||||
export type ViewerElementRef = RefObject<HTMLDivElement | null>;
|
||||
+61
-9
@@ -48,15 +48,11 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
if !gallery_dir.is_dir() {
|
||||
bail!(
|
||||
"gallery directory does not exist: {}",
|
||||
gallery_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
gallery_dir = canonical_directory(&gallery_dir, "gallery")?;
|
||||
std::fs::create_dir_all(&cache_dir)
|
||||
.with_context(|| format!("cannot create cache directory: {}", cache_dir.display()))?;
|
||||
cache_dir = canonical_directory(&cache_dir, "cache")?;
|
||||
ensure_cache_is_external(&gallery_dir, &cache_dir)?;
|
||||
|
||||
Ok(Self {
|
||||
gallery_dir,
|
||||
@@ -69,6 +65,27 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_directory(path: &std::path::Path, label: &str) -> Result<PathBuf> {
|
||||
if !path.is_dir() {
|
||||
bail!("{label} directory does not exist: {}", path.display());
|
||||
}
|
||||
std::fs::canonicalize(path)
|
||||
.with_context(|| format!("cannot resolve {label} directory: {}", path.display()))
|
||||
}
|
||||
|
||||
fn ensure_cache_is_external(
|
||||
gallery_dir: &std::path::Path,
|
||||
cache_dir: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
if cache_dir.starts_with(gallery_dir) {
|
||||
bail!(
|
||||
"cache directory must be outside the gallery directory: {}",
|
||||
cache_dir.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn value(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<String> {
|
||||
args.next()
|
||||
.with_context(|| format!("{flag} requires a value"))
|
||||
@@ -115,11 +132,46 @@ fn print_help() {
|
||||
Usage: pixhelf [OPTIONS]\n\n\
|
||||
Options:\n \
|
||||
--gallery-dir PATH Gallery root (default: ./pic)\n \
|
||||
--cache-dir PATH Thumbnail and viewer preview cache (default: ./.pixhelf-cache/thumbnails)\n \
|
||||
--cache-dir PATH Thumbnail cache (default: ./.pixhelf-cache/thumbnails)\n \
|
||||
--listen HOST:PORT Listen address (default: 0.0.0.0:3002)\n \
|
||||
--initial-batch N Thumbnails prepared before serving (default: 60)\n \
|
||||
--initial-batch N Priority thumbnail batch (default: 60)\n \
|
||||
--workers N Background thumbnail workers (default: 2-4, based on CPU)\n \
|
||||
--scan-interval SEC Gallery rescan interval (default: 10)\n \
|
||||
-h, --help Show this help"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_numeric_ranges() {
|
||||
let mut valid = ["4".to_owned()].into_iter();
|
||||
assert_eq!(number_value(&mut valid, "--workers", 1, 16).unwrap(), 4);
|
||||
|
||||
let mut invalid = ["0".to_owned()].into_iter();
|
||||
assert!(number_value(&mut invalid, "--workers", 1, 16).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_existing_directories() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
assert_eq!(
|
||||
canonical_directory(temp.path(), "test").unwrap(),
|
||||
temp.path().canonicalize().unwrap()
|
||||
);
|
||||
assert!(canonical_directory(&temp.path().join("missing"), "test").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_cache_directories_inside_the_gallery() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let gallery = temp.path().join("gallery");
|
||||
let nested_cache = gallery.join("cache");
|
||||
let external_cache = temp.path().join("cache");
|
||||
|
||||
assert!(ensure_cache_is_external(&gallery, &nested_cache).is_err());
|
||||
assert!(ensure_cache_is_external(&gallery, &external_cache).is_ok());
|
||||
}
|
||||
}
|
||||
+83
-30
@@ -6,7 +6,7 @@ use std::{
|
||||
time::UNIX_EPOCH,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use image::{ImageDecoder, ImageReader, metadata::Orientation};
|
||||
use serde::Serialize;
|
||||
use tracing::warn;
|
||||
@@ -37,22 +37,13 @@ pub struct Album {
|
||||
#[derive(Clone, Default)]
|
||||
pub struct GalleryIndex {
|
||||
pub images: Vec<Arc<ImageRecord>>,
|
||||
pub by_id: HashMap<String, Arc<ImageRecord>>,
|
||||
pub albums: Vec<Album>,
|
||||
pub revision: String,
|
||||
}
|
||||
|
||||
impl GalleryIndex {
|
||||
pub fn get(&self, id: &str) -> Option<Arc<ImageRecord>> {
|
||||
self.by_id.get(id).cloned()
|
||||
}
|
||||
|
||||
pub fn same_revision(&self, other: &Self) -> bool {
|
||||
self.images.len() == other.images.len()
|
||||
&& self
|
||||
.images
|
||||
.iter()
|
||||
.zip(&other.images)
|
||||
.all(|(left, right)| left.id == right.id)
|
||||
self.revision == other.revision
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,14 +59,17 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
|
||||
.follow_links(false)
|
||||
.sort_by_file_name()
|
||||
.into_iter()
|
||||
.filter_map(|entry| match entry {
|
||||
Ok(entry) => Some(entry),
|
||||
{
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(error) if previous.is_some() => {
|
||||
return Err(anyhow!(error).context("gallery rescan was incomplete"));
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(%error, "cannot inspect gallery entry");
|
||||
None
|
||||
continue;
|
||||
}
|
||||
})
|
||||
{
|
||||
};
|
||||
if !entry.file_type().is_file() || !is_supported_image(entry.path()) {
|
||||
continue;
|
||||
}
|
||||
@@ -84,11 +78,18 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.with_context(|| format!("{} is outside the gallery root", path.display()))?;
|
||||
let relative_path = path_to_url(relative);
|
||||
let Some(relative_path) = path_to_url(relative) else {
|
||||
warn!(path = %path.display(), "image path is not valid UTF-8");
|
||||
continue;
|
||||
};
|
||||
let existing = previous_by_path.get(relative_path.as_str()).copied();
|
||||
let metadata = match fs::metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => {
|
||||
warn!(path = %path.display(), %error, "cannot read image metadata");
|
||||
if let Some(existing) = existing {
|
||||
images.push(Arc::clone(existing));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -100,7 +101,7 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
|
||||
let modified_ns = modified.as_nanos();
|
||||
let size = metadata.len();
|
||||
|
||||
if let Some(existing) = previous_by_path.get(relative_path.as_str())
|
||||
if let Some(existing) = existing
|
||||
&& existing.size == size
|
||||
&& existing.modified_ns == modified_ns
|
||||
{
|
||||
@@ -112,13 +113,16 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
|
||||
Ok(dimensions) => dimensions,
|
||||
Err(error) => {
|
||||
warn!(path = %path.display(), %error, "cannot decode image header");
|
||||
if let Some(existing) = existing {
|
||||
images.push(Arc::clone(existing));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let album = relative
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.map(path_to_url)
|
||||
.and_then(path_to_url)
|
||||
.unwrap_or_default();
|
||||
let name = path
|
||||
.file_name()
|
||||
@@ -144,6 +148,7 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
|
||||
images.sort_by(|left, right| {
|
||||
natord::compare_ignore_case(&left.relative_path, &right.relative_path)
|
||||
});
|
||||
let revision = index_revision(&images);
|
||||
|
||||
let mut album_counts = BTreeMap::<String, usize>::new();
|
||||
for image in &images {
|
||||
@@ -162,18 +167,23 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
|
||||
count,
|
||||
})
|
||||
.collect();
|
||||
let by_id = images
|
||||
.iter()
|
||||
.map(|record| (record.id.clone(), Arc::clone(record)))
|
||||
.collect();
|
||||
|
||||
Ok(GalleryIndex {
|
||||
images,
|
||||
by_id,
|
||||
albums,
|
||||
revision,
|
||||
})
|
||||
}
|
||||
|
||||
fn index_revision(images: &[Arc<ImageRecord>]) -> String {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(b"pixhelf-index-v1\0");
|
||||
for image in images {
|
||||
hasher.update(image.id.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
}
|
||||
hasher.finalize().to_hex().to_string()
|
||||
}
|
||||
|
||||
fn probe_dimensions(path: &Path) -> Result<(u32, u32)> {
|
||||
let reader = ImageReader::open(path)?.with_guessed_format()?;
|
||||
let mut decoder = reader.into_decoder()?;
|
||||
@@ -213,11 +223,11 @@ fn image_id(relative_path: &str, size: u64, modified_ns: u128) -> String {
|
||||
hasher.finalize().to_hex().to_string()
|
||||
}
|
||||
|
||||
fn path_to_url(path: &Path) -> String {
|
||||
fn path_to_url(path: &Path) -> Option<String> {
|
||||
path.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
.map(|component| component.as_os_str().to_str())
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.map(|components| components.join("/"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -256,4 +266,47 @@ mod tests {
|
||||
assert_eq!((index.images[0].width, index.images[0].height), (20, 10));
|
||||
assert_eq!(index.albums[0].count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rescan_keeps_previous_record_while_file_is_invalid() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("image.png");
|
||||
image::RgbImage::new(20, 10).save(&path).unwrap();
|
||||
let previous = scan_gallery(temp.path(), None).unwrap();
|
||||
let previous_id = previous.images[0].id.clone();
|
||||
|
||||
fs::write(&path, "incomplete image data").unwrap();
|
||||
let updated = scan_gallery(temp.path(), Some(&previous)).unwrap();
|
||||
|
||||
assert_eq!(updated.images.len(), 1);
|
||||
assert_eq!(updated.images[0].id, previous_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_rescan_does_not_return_a_partial_index() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let previous = GalleryIndex::default();
|
||||
let missing = temp.path().join("missing");
|
||||
|
||||
assert!(scan_gallery(&missing, Some(&previous)).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn skips_non_utf8_paths() {
|
||||
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
image::RgbImage::new(20, 10)
|
||||
.save(temp.path().join("valid.png"))
|
||||
.unwrap();
|
||||
let invalid_name = OsString::from_vec(vec![0xff, b'.', b'p', b'n', b'g']);
|
||||
image::RgbImage::new(20, 10)
|
||||
.save(temp.path().join(invalid_name))
|
||||
.unwrap();
|
||||
|
||||
let index = scan_gallery(temp.path(), None).unwrap();
|
||||
assert_eq!(index.images.len(), 1);
|
||||
assert_eq!(index.images[0].name, "valid.png");
|
||||
}
|
||||
}
|
||||
+36
-22
@@ -5,12 +5,12 @@ mod web;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use anyhow::{Context, Result};
|
||||
use config::Config;
|
||||
use gallery::scan_gallery;
|
||||
use thumbs::{ThumbnailManager, ViewerPreviewManager};
|
||||
use thumbs::ThumbnailManager;
|
||||
use tokio::{net::TcpListener, sync::RwLock};
|
||||
use tracing::{error, info};
|
||||
use tracing::{error, info, warn};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use web::AppState;
|
||||
|
||||
@@ -31,9 +31,9 @@ async fn main() -> Result<()> {
|
||||
.await
|
||||
.context("gallery scan task stopped")??;
|
||||
if index.images.is_empty() {
|
||||
bail!(
|
||||
"no supported images found in {}",
|
||||
config.gallery_dir.display()
|
||||
warn!(
|
||||
gallery = %config.gallery_dir.display(),
|
||||
"gallery is empty; waiting for supported images"
|
||||
);
|
||||
}
|
||||
info!(
|
||||
@@ -50,11 +50,9 @@ async fn main() -> Result<()> {
|
||||
.collect();
|
||||
let index = Arc::new(RwLock::new(index));
|
||||
let thumbnails = ThumbnailManager::new(config.cache_dir.clone())?;
|
||||
let previews = ViewerPreviewManager::new(config.cache_dir.clone())?;
|
||||
{
|
||||
let current = index.read().await;
|
||||
thumbnails.reconcile(¤t.images);
|
||||
previews.reconcile(¤t.images);
|
||||
}
|
||||
thumbnails.start_workers(config.workers);
|
||||
let initial_thumbnail_manager = Arc::clone(&thumbnails);
|
||||
@@ -67,16 +65,11 @@ async fn main() -> Result<()> {
|
||||
let state = AppState {
|
||||
index: Arc::clone(&index),
|
||||
thumbnails: Arc::clone(&thumbnails),
|
||||
previews: Arc::clone(&previews),
|
||||
};
|
||||
let cleanup_manager = Arc::clone(&thumbnails);
|
||||
tokio::spawn(async move {
|
||||
cleanup_manager.cleanup_stale().await;
|
||||
});
|
||||
let preview_cleanup_manager = Arc::clone(&previews);
|
||||
tokio::spawn(async move {
|
||||
preview_cleanup_manager.cleanup_stale().await;
|
||||
});
|
||||
tokio::spawn(rescan_gallery(state.clone(), config.clone()));
|
||||
|
||||
let listener = TcpListener::bind(config.listen)
|
||||
@@ -95,14 +88,15 @@ async fn rescan_gallery(state: AppState, config: Config) {
|
||||
tokio::time::sleep(config.scan_interval).await;
|
||||
let previous = state.index.read().await.clone();
|
||||
let root = config.gallery_dir.clone();
|
||||
let previous_for_scan = previous.clone();
|
||||
let scan =
|
||||
tokio::task::spawn_blocking(move || scan_gallery(&root, Some(&previous_for_scan)))
|
||||
.await;
|
||||
let scan = tokio::task::spawn_blocking(move || {
|
||||
let updated = scan_gallery(&root, Some(&previous));
|
||||
(previous, updated)
|
||||
})
|
||||
.await;
|
||||
|
||||
let updated = match scan {
|
||||
Ok(Ok(index)) => index,
|
||||
Ok(Err(error)) => {
|
||||
let (previous, updated) = match scan {
|
||||
Ok((previous, Ok(updated))) => (previous, updated),
|
||||
Ok((_, Err(error))) => {
|
||||
error!(%error, "gallery rescan failed");
|
||||
continue;
|
||||
}
|
||||
@@ -118,16 +112,36 @@ async fn rescan_gallery(state: AppState, config: Config) {
|
||||
let old_count = previous.images.len();
|
||||
let new_count = updated.images.len();
|
||||
state.thumbnails.reconcile(&updated.images);
|
||||
state.previews.reconcile(&updated.images);
|
||||
*state.index.write().await = updated;
|
||||
state.thumbnails.cleanup_stale().await;
|
||||
state.previews.cleanup_stale().await;
|
||||
info!(old_count, new_count, "gallery changes indexed");
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||
Ok(mut terminate) => {
|
||||
tokio::select! {
|
||||
() = wait_for_ctrl_c() => {}
|
||||
_ = terminate.recv() => {}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
error!(%error, "cannot install SIGTERM handler");
|
||||
wait_for_ctrl_c().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
wait_for_ctrl_c().await;
|
||||
}
|
||||
|
||||
async fn wait_for_ctrl_c() {
|
||||
if let Err(error) = tokio::signal::ctrl_c().await {
|
||||
error!(%error, "cannot install shutdown handler");
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
}
|
||||
+286
-326
@@ -1,22 +1,24 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet, VecDeque},
|
||||
fs::{self, File},
|
||||
error::Error,
|
||||
fmt,
|
||||
fs::{self, OpenOptions},
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc, Mutex, RwLock,
|
||||
atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
|
||||
Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use image::{
|
||||
DynamicImage, GenericImageView, ImageDecoder, ImageReader, imageops::FilterType,
|
||||
metadata::Orientation,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{Notify, Semaphore};
|
||||
use tokio::sync::Notify;
|
||||
use tracing::{info, warn};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
@@ -28,11 +30,6 @@ const CACHE_VERSION: &str = "720-webp-q82-v1";
|
||||
const THUMBNAIL_FILTER: FilterType = FilterType::Triangle;
|
||||
const MAX_ATTEMPTS: u8 = 3;
|
||||
|
||||
const PENDING: u8 = 0;
|
||||
const PROCESSING: u8 = 1;
|
||||
const READY: u8 = 2;
|
||||
const FAILED: u8 = 3;
|
||||
|
||||
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub struct ThumbnailManager {
|
||||
@@ -42,188 +39,38 @@ pub struct ThumbnailManager {
|
||||
initial_ready: AtomicBool,
|
||||
}
|
||||
|
||||
const PREVIEW_CACHE_VERSION: &str = "2560-webp-q88-v2";
|
||||
const PREVIEW_EDGE: u32 = 2560;
|
||||
const PREVIEW_QUALITY: f32 = 88.0;
|
||||
const PREVIEW_FILTER: FilterType = FilterType::CatmullRom;
|
||||
const PREVIEW_PENDING: u8 = 0;
|
||||
const PREVIEW_PROCESSING: u8 = 1;
|
||||
const PREVIEW_READY: u8 = 2;
|
||||
const PREVIEW_FAILED: u8 = 3;
|
||||
|
||||
/// Viewer previews are intentionally independent from the eager thumbnail queue.
|
||||
/// They are generated only when a user opens an image, then retained immutably.
|
||||
pub struct ViewerPreviewManager {
|
||||
cache_root: PathBuf,
|
||||
entries: RwLock<HashMap<String, Arc<PreviewEntry>>>,
|
||||
slots: Arc<Semaphore>,
|
||||
#[derive(Debug)]
|
||||
pub enum ThumbnailError {
|
||||
NotFound,
|
||||
Generation(String),
|
||||
}
|
||||
|
||||
struct PreviewEntry {
|
||||
record: Arc<ImageRecord>,
|
||||
cache_path: PathBuf,
|
||||
state: AtomicU8,
|
||||
last_error: Mutex<Option<String>>,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
impl ViewerPreviewManager {
|
||||
pub fn new(cache_dir: PathBuf) -> Result<Arc<Self>> {
|
||||
let cache_root = cache_dir.join(PREVIEW_CACHE_VERSION);
|
||||
fs::create_dir_all(&cache_root).with_context(|| {
|
||||
format!(
|
||||
"cannot create viewer preview cache: {}",
|
||||
cache_root.display()
|
||||
)
|
||||
})?;
|
||||
purge_temporary_files(&cache_root);
|
||||
Ok(Arc::new(Self {
|
||||
cache_root,
|
||||
entries: RwLock::new(HashMap::new()),
|
||||
slots: Arc::new(Semaphore::new(2)),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn reconcile(&self, records: &[Arc<ImageRecord>]) {
|
||||
let current_ids: HashSet<&str> = records.iter().map(|record| record.id.as_str()).collect();
|
||||
let mut entries = self.entries.write().expect("viewer preview entries lock");
|
||||
entries.retain(|id, _| current_ids.contains(id.as_str()));
|
||||
for record in records {
|
||||
if entries.contains_key(&record.id) {
|
||||
continue;
|
||||
}
|
||||
let cache_path = shard_path(&self.cache_root, &record.id);
|
||||
let state = if valid_cache_file(&cache_path) {
|
||||
PREVIEW_READY
|
||||
} else {
|
||||
PREVIEW_PENDING
|
||||
};
|
||||
entries.insert(
|
||||
record.id.clone(),
|
||||
Arc::new(PreviewEntry {
|
||||
record: Arc::clone(record),
|
||||
cache_path,
|
||||
state: AtomicU8::new(state),
|
||||
last_error: Mutex::new(None),
|
||||
notify: Notify::new(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ensure_ready(&self, id: &str) -> Result<PathBuf> {
|
||||
let entry = self
|
||||
.entries
|
||||
.read()
|
||||
.expect("viewer preview entries lock")
|
||||
.get(id)
|
||||
.cloned()
|
||||
.with_context(|| format!("unknown image: {id}"))?;
|
||||
|
||||
loop {
|
||||
let notified = entry.notify.notified();
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
match entry.state.load(Ordering::Acquire) {
|
||||
PREVIEW_READY if valid_cache_file(&entry.cache_path) => {
|
||||
return Ok(entry.cache_path.clone());
|
||||
}
|
||||
PREVIEW_READY => {
|
||||
let _ = entry.state.compare_exchange(
|
||||
PREVIEW_READY,
|
||||
PREVIEW_PENDING,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
);
|
||||
}
|
||||
PREVIEW_PENDING => {
|
||||
if entry
|
||||
.state
|
||||
.compare_exchange(
|
||||
PREVIEW_PENDING,
|
||||
PREVIEW_PROCESSING,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
self.start_generation(Arc::clone(&entry));
|
||||
}
|
||||
}
|
||||
PREVIEW_PROCESSING => notified.as_mut().await,
|
||||
PREVIEW_FAILED => {
|
||||
let message = entry
|
||||
.last_error
|
||||
.lock()
|
||||
.expect("viewer preview error lock")
|
||||
.clone()
|
||||
.unwrap_or_else(|| "viewer preview generation failed".to_owned());
|
||||
bail!(message);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_generation(&self, entry: Arc<PreviewEntry>) {
|
||||
let slots = Arc::clone(&self.slots);
|
||||
tokio::spawn(async move {
|
||||
let record = Arc::clone(&entry.record);
|
||||
let output = entry.cache_path.clone();
|
||||
let result = async move {
|
||||
let _permit = slots
|
||||
.acquire_owned()
|
||||
.await
|
||||
.map_err(|_| anyhow!("viewer preview worker stopped"))?;
|
||||
tokio::task::spawn_blocking(move || generate_preview(&record, &output))
|
||||
.await
|
||||
.map_err(|error| anyhow!("viewer preview worker stopped: {error}"))?
|
||||
}
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
entry.state.store(PREVIEW_READY, Ordering::Release);
|
||||
entry.notify.notify_waiters();
|
||||
}
|
||||
Err(error) => {
|
||||
*entry.last_error.lock().expect("viewer preview error lock") =
|
||||
Some(error.to_string());
|
||||
entry.state.store(PREVIEW_FAILED, Ordering::Release);
|
||||
entry.notify.notify_waiters();
|
||||
warn!(image = %entry.record.relative_path, %error, "viewer preview generation failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn cleanup_stale(&self) {
|
||||
let valid_ids: HashSet<String> = self
|
||||
.entries
|
||||
.read()
|
||||
.expect("viewer preview entries lock")
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
let root = self.cache_root.clone();
|
||||
if let Err(error) =
|
||||
tokio::task::spawn_blocking(move || cleanup_cache(&root, &valid_ids)).await
|
||||
{
|
||||
warn!(%error, "viewer preview cache cleanup task failed");
|
||||
impl fmt::Display for ThumbnailError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::NotFound => formatter.write_str("unknown image"),
|
||||
Self::Generation(message) => formatter.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for ThumbnailError {}
|
||||
|
||||
struct ThumbEntry {
|
||||
record: Arc<ImageRecord>,
|
||||
cache_path: PathBuf,
|
||||
state: AtomicU8,
|
||||
urgent: AtomicBool,
|
||||
attempts: AtomicU8,
|
||||
last_error: Mutex<Option<String>>,
|
||||
state: Mutex<ThumbState>,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
enum ThumbState {
|
||||
Pending { urgent: bool, attempts: u8 },
|
||||
Processing { urgent: bool, attempts: u8 },
|
||||
Ready,
|
||||
Failed(String),
|
||||
Removed,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct JobQueue {
|
||||
state: Mutex<QueueState>,
|
||||
@@ -237,6 +84,21 @@ struct QueueState {
|
||||
queued: HashSet<String>,
|
||||
}
|
||||
|
||||
fn mutex_lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
mutex
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn read_lock<T>(lock: &RwLock<T>) -> RwLockReadGuard<'_, T> {
|
||||
lock.read().unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn write_lock<T>(lock: &RwLock<T>) -> RwLockWriteGuard<'_, T> {
|
||||
lock.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThumbnailStatus {
|
||||
@@ -267,8 +129,15 @@ impl ThumbnailManager {
|
||||
let current_ids: HashSet<&str> = records.iter().map(|record| record.id.as_str()).collect();
|
||||
let mut missing = Vec::new();
|
||||
{
|
||||
let mut entries = self.entries.write().expect("thumbnail entries lock");
|
||||
entries.retain(|id, _| current_ids.contains(id.as_str()));
|
||||
let mut entries = write_lock(&self.entries);
|
||||
entries.retain(|id, entry| {
|
||||
let retained = current_ids.contains(id.as_str());
|
||||
if !retained {
|
||||
*mutex_lock(&entry.state) = ThumbState::Removed;
|
||||
entry.notify.notify_waiters();
|
||||
}
|
||||
retained
|
||||
});
|
||||
|
||||
for record in records {
|
||||
if entries.contains_key(&record.id) {
|
||||
@@ -276,26 +145,27 @@ impl ThumbnailManager {
|
||||
}
|
||||
let cache_path = self.cache_path(&record.id);
|
||||
let state = if valid_cache_file(&cache_path) {
|
||||
READY
|
||||
ThumbState::Ready
|
||||
} else {
|
||||
missing.push(record.id.clone());
|
||||
PENDING
|
||||
ThumbState::Pending {
|
||||
urgent: false,
|
||||
attempts: 0,
|
||||
}
|
||||
};
|
||||
entries.insert(
|
||||
record.id.clone(),
|
||||
Arc::new(ThumbEntry {
|
||||
record: Arc::clone(record),
|
||||
cache_path,
|
||||
state: AtomicU8::new(state),
|
||||
urgent: AtomicBool::new(false),
|
||||
attempts: AtomicU8::new(0),
|
||||
last_error: Mutex::new(None),
|
||||
state: Mutex::new(state),
|
||||
notify: Notify::new(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self.queue.retain(¤t_ids);
|
||||
for id in missing {
|
||||
self.queue.push_background(id);
|
||||
}
|
||||
@@ -329,24 +199,25 @@ impl ThumbnailManager {
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn ensure_ready(&self, id: &str) -> Result<PathBuf> {
|
||||
pub async fn ensure_ready(&self, id: &str) -> Result<PathBuf, ThumbnailError> {
|
||||
self.promote(id);
|
||||
self.wait_for(id).await
|
||||
}
|
||||
|
||||
pub fn status(&self) -> ThumbnailStatus {
|
||||
let entries = self.entries.read().expect("thumbnail entries lock");
|
||||
let entries = read_lock(&self.entries);
|
||||
let mut ready = 0;
|
||||
let mut queued = 0;
|
||||
let mut processing = 0;
|
||||
let mut failed = 0;
|
||||
|
||||
for entry in entries.values() {
|
||||
match entry.state.load(Ordering::Acquire) {
|
||||
READY => ready += 1,
|
||||
PROCESSING => processing += 1,
|
||||
FAILED => failed += 1,
|
||||
_ => queued += 1,
|
||||
match &*mutex_lock(&entry.state) {
|
||||
ThumbState::Ready => ready += 1,
|
||||
ThumbState::Processing { .. } => processing += 1,
|
||||
ThumbState::Failed(_) => failed += 1,
|
||||
ThumbState::Pending { .. } => queued += 1,
|
||||
ThumbState::Removed => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,13 +234,7 @@ impl ThumbnailManager {
|
||||
}
|
||||
|
||||
pub async fn cleanup_stale(&self) {
|
||||
let valid_ids: HashSet<String> = self
|
||||
.entries
|
||||
.read()
|
||||
.expect("thumbnail entries lock")
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
let valid_ids: HashSet<String> = read_lock(&self.entries).keys().cloned().collect();
|
||||
let root = self.cache_root.clone();
|
||||
if let Err(error) =
|
||||
tokio::task::spawn_blocking(move || cleanup_cache(&root, &valid_ids)).await
|
||||
@@ -379,54 +244,67 @@ impl ThumbnailManager {
|
||||
}
|
||||
|
||||
fn promote(&self, id: &str) {
|
||||
let entry = self
|
||||
.entries
|
||||
.read()
|
||||
.expect("thumbnail entries lock")
|
||||
.get(id)
|
||||
.cloned();
|
||||
if let Some(entry) = entry
|
||||
&& entry.state.load(Ordering::Acquire) == PENDING
|
||||
{
|
||||
entry.urgent.store(true, Ordering::Release);
|
||||
let entry = read_lock(&self.entries).get(id).cloned();
|
||||
let Some(entry) = entry else {
|
||||
return;
|
||||
};
|
||||
|
||||
let should_queue = {
|
||||
let mut state = mutex_lock(&entry.state);
|
||||
match &mut *state {
|
||||
ThumbState::Pending { urgent, .. } => {
|
||||
*urgent = true;
|
||||
true
|
||||
}
|
||||
ThumbState::Failed(_) => {
|
||||
*state = ThumbState::Pending {
|
||||
urgent: true,
|
||||
attempts: 0,
|
||||
};
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
if should_queue {
|
||||
self.queue.push_urgent(id.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for(&self, id: &str) -> Result<PathBuf> {
|
||||
let entry = self
|
||||
.entries
|
||||
.read()
|
||||
.expect("thumbnail entries lock")
|
||||
async fn wait_for(&self, id: &str) -> Result<PathBuf, ThumbnailError> {
|
||||
let entry = read_lock(&self.entries)
|
||||
.get(id)
|
||||
.cloned()
|
||||
.with_context(|| format!("unknown image: {id}"))?;
|
||||
.ok_or(ThumbnailError::NotFound)?;
|
||||
|
||||
loop {
|
||||
let notified = entry.notify.notified();
|
||||
match entry.state.load(Ordering::Acquire) {
|
||||
READY if valid_cache_file(&entry.cache_path) => {
|
||||
return Ok(entry.cache_path.clone());
|
||||
}
|
||||
READY => {
|
||||
if entry
|
||||
.state
|
||||
.compare_exchange(READY, PENDING, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
self.queue.push_urgent(id.to_owned());
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
let should_queue = {
|
||||
let mut state = mutex_lock(&entry.state);
|
||||
match &*state {
|
||||
ThumbState::Ready if valid_cache_file(&entry.cache_path) => {
|
||||
return Ok(entry.cache_path.clone());
|
||||
}
|
||||
ThumbState::Ready => {
|
||||
*state = ThumbState::Pending {
|
||||
urgent: true,
|
||||
attempts: 0,
|
||||
};
|
||||
true
|
||||
}
|
||||
ThumbState::Failed(message) => {
|
||||
return Err(ThumbnailError::Generation(message.clone()));
|
||||
}
|
||||
ThumbState::Removed => return Err(ThumbnailError::NotFound),
|
||||
ThumbState::Pending { .. } | ThumbState::Processing { .. } => false,
|
||||
}
|
||||
FAILED => {
|
||||
let message = entry
|
||||
.last_error
|
||||
.lock()
|
||||
.expect("thumbnail error lock")
|
||||
.clone()
|
||||
.unwrap_or_else(|| "thumbnail generation failed".to_owned());
|
||||
bail!(message);
|
||||
}
|
||||
_ => notified.await,
|
||||
};
|
||||
if should_queue {
|
||||
self.queue.push_urgent(id.to_owned());
|
||||
} else {
|
||||
notified.as_mut().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,21 +312,17 @@ impl ThumbnailManager {
|
||||
async fn worker_loop(self: Arc<Self>, worker: usize) {
|
||||
loop {
|
||||
let id = self.queue.pop().await;
|
||||
let entry = self
|
||||
.entries
|
||||
.read()
|
||||
.expect("thumbnail entries lock")
|
||||
.get(&id)
|
||||
.cloned();
|
||||
let entry = read_lock(&self.entries).get(&id).cloned();
|
||||
let Some(entry) = entry else {
|
||||
continue;
|
||||
};
|
||||
if entry
|
||||
.state
|
||||
.compare_exchange(PENDING, PROCESSING, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
let mut state = mutex_lock(&entry.state);
|
||||
let ThumbState::Pending { urgent, attempts } = &*state else {
|
||||
continue;
|
||||
};
|
||||
let (urgent, attempts) = (*urgent, *attempts);
|
||||
*state = ThumbState::Processing { urgent, attempts };
|
||||
}
|
||||
|
||||
let record = Arc::clone(&entry.record);
|
||||
@@ -460,22 +334,50 @@ impl ThumbnailManager {
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
entry.state.store(READY, Ordering::Release);
|
||||
entry.notify.notify_waiters();
|
||||
let (ready, removed) = {
|
||||
let mut state = mutex_lock(&entry.state);
|
||||
if matches!(*state, ThumbState::Processing { .. }) {
|
||||
*state = ThumbState::Ready;
|
||||
(true, false)
|
||||
} else {
|
||||
(false, matches!(*state, ThumbState::Removed))
|
||||
}
|
||||
};
|
||||
if ready {
|
||||
entry.notify.notify_waiters();
|
||||
} else if removed {
|
||||
let entries = read_lock(&self.entries);
|
||||
if !entries.contains_key(&id)
|
||||
&& let Err(error) = fs::remove_file(&entry.cache_path)
|
||||
&& error.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
warn!(path = %entry.cache_path.display(), %error, "cannot remove stale thumbnail");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let attempt = entry.attempts.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
*entry.last_error.lock().expect("thumbnail error lock") =
|
||||
Some(error.to_string());
|
||||
let transition = {
|
||||
let mut state = mutex_lock(&entry.state);
|
||||
let ThumbState::Processing { urgent, attempts } = &*state else {
|
||||
continue;
|
||||
};
|
||||
let (urgent, attempt) = (*urgent, attempts.saturating_add(1));
|
||||
let retry = attempt < MAX_ATTEMPTS;
|
||||
if retry {
|
||||
*state = ThumbState::Pending {
|
||||
urgent,
|
||||
attempts: attempt,
|
||||
};
|
||||
} else {
|
||||
*state = ThumbState::Failed(error.to_string());
|
||||
}
|
||||
(attempt, retry)
|
||||
};
|
||||
let (attempt, retry) = transition;
|
||||
warn!(worker, image = %entry.record.relative_path, attempt, %error, "thumbnail generation failed");
|
||||
|
||||
if attempt < MAX_ATTEMPTS {
|
||||
entry.state.store(PENDING, Ordering::Release);
|
||||
entry.notify.notify_waiters();
|
||||
entry.notify.notify_waiters();
|
||||
if retry {
|
||||
self.schedule_retry(entry, id, attempt);
|
||||
} else {
|
||||
entry.state.store(FAILED, Ordering::Release);
|
||||
entry.notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -491,10 +393,11 @@ impl ThumbnailManager {
|
||||
tokio::spawn(async move {
|
||||
let delay = Duration::from_millis(200 * (1 << (attempt - 1)));
|
||||
tokio::time::sleep(delay).await;
|
||||
if entry.state.load(Ordering::Acquire) != PENDING {
|
||||
return;
|
||||
}
|
||||
if entry.urgent.load(Ordering::Acquire) {
|
||||
let urgent = match &*mutex_lock(&entry.state) {
|
||||
ThumbState::Pending { urgent, .. } => *urgent,
|
||||
_ => return,
|
||||
};
|
||||
if urgent {
|
||||
manager.queue.push_urgent(id);
|
||||
} else {
|
||||
manager.queue.push_background(id);
|
||||
@@ -504,9 +407,18 @@ impl ThumbnailManager {
|
||||
}
|
||||
|
||||
impl JobQueue {
|
||||
fn retain(&self, valid_ids: &HashSet<&str>) {
|
||||
let mut state = mutex_lock(&self.state);
|
||||
state.urgent.retain(|id| valid_ids.contains(id.as_str()));
|
||||
state
|
||||
.background
|
||||
.retain(|id| valid_ids.contains(id.as_str()));
|
||||
state.queued.retain(|id| valid_ids.contains(id.as_str()));
|
||||
}
|
||||
|
||||
fn push_urgent(&self, id: String) {
|
||||
let should_notify = {
|
||||
let mut state = self.state.lock().expect("thumbnail queue lock");
|
||||
let mut state = mutex_lock(&self.state);
|
||||
if state.queued.contains(&id) {
|
||||
if let Some(position) = state.background.iter().position(|queued| queued == &id) {
|
||||
state.background.remove(position);
|
||||
@@ -529,7 +441,7 @@ impl JobQueue {
|
||||
|
||||
fn push_background(&self, id: String) {
|
||||
let should_notify = {
|
||||
let mut state = self.state.lock().expect("thumbnail queue lock");
|
||||
let mut state = mutex_lock(&self.state);
|
||||
if state.queued.insert(id.clone()) {
|
||||
state.background.push_back(id);
|
||||
true
|
||||
@@ -546,8 +458,10 @@ impl JobQueue {
|
||||
async fn pop(&self) -> String {
|
||||
loop {
|
||||
let notified = self.notify.notified();
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
if let Some(id) = {
|
||||
let mut state = self.state.lock().expect("thumbnail queue lock");
|
||||
let mut state = mutex_lock(&self.state);
|
||||
let id = state
|
||||
.urgent
|
||||
.pop_front()
|
||||
@@ -559,7 +473,7 @@ impl JobQueue {
|
||||
} {
|
||||
return id;
|
||||
}
|
||||
notified.await;
|
||||
notified.as_mut().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,17 +500,6 @@ fn generate_thumbnail(record: &ImageRecord, output: &Path) -> Result<()> {
|
||||
)
|
||||
}
|
||||
|
||||
fn generate_preview(record: &ImageRecord, output: &Path) -> Result<()> {
|
||||
generate_image_cache(
|
||||
record,
|
||||
output,
|
||||
PREVIEW_EDGE,
|
||||
PREVIEW_FILTER,
|
||||
PREVIEW_QUALITY,
|
||||
"viewer preview",
|
||||
)
|
||||
}
|
||||
|
||||
fn generate_image_cache(
|
||||
record: &ImageRecord,
|
||||
output: &Path,
|
||||
@@ -650,9 +553,12 @@ fn write_webp(image: &DynamicImage, output: &Path, quality: f32) -> Result<()> {
|
||||
sequence
|
||||
));
|
||||
let write_result = (|| -> Result<()> {
|
||||
let mut file = File::create(&temporary)?;
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&temporary)?;
|
||||
file.write_all(encoded.as_ref())?;
|
||||
file.flush()?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
fs::rename(&temporary, output)?;
|
||||
Ok(())
|
||||
@@ -664,32 +570,38 @@ fn write_webp(image: &DynamicImage, output: &Path, quality: f32) -> Result<()> {
|
||||
}
|
||||
|
||||
fn cleanup_cache(root: &Path, valid_ids: &HashSet<String>) {
|
||||
for entry in WalkDir::new(root).min_depth(1).into_iter().flatten() {
|
||||
if !entry.file_type().is_file()
|
||||
|| entry.path().extension().and_then(|ext| ext.to_str()) != Some("webp")
|
||||
{
|
||||
continue;
|
||||
visit_cache_files(root, |path| {
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("webp") {
|
||||
return;
|
||||
}
|
||||
let id = entry
|
||||
.path()
|
||||
let id = path
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.unwrap_or_default();
|
||||
if !valid_ids.contains(id)
|
||||
&& let Err(error) = fs::remove_file(entry.path())
|
||||
&& let Err(error) = fs::remove_file(path)
|
||||
{
|
||||
warn!(path = %entry.path().display(), %error, "cannot remove stale thumbnail");
|
||||
warn!(path = %path.display(), %error, "cannot remove stale thumbnail");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn purge_temporary_files(root: &Path) {
|
||||
for entry in WalkDir::new(root).min_depth(1).into_iter().flatten() {
|
||||
if entry.file_type().is_file()
|
||||
&& entry.path().extension().and_then(|ext| ext.to_str()) == Some("tmp")
|
||||
&& let Err(error) = fs::remove_file(entry.path())
|
||||
visit_cache_files(root, |path| {
|
||||
if path.extension().and_then(|ext| ext.to_str()) == Some("tmp")
|
||||
&& let Err(error) = fs::remove_file(path)
|
||||
{
|
||||
warn!(path = %entry.path().display(), %error, "cannot remove temporary thumbnail");
|
||||
warn!(path = %path.display(), %error, "cannot remove temporary thumbnail");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn visit_cache_files(root: &Path, mut visit: impl FnMut(&Path)) {
|
||||
for entry in WalkDir::new(root).min_depth(1) {
|
||||
match entry {
|
||||
Ok(entry) if entry.file_type().is_file() => visit(entry.path()),
|
||||
Ok(_) => {}
|
||||
Err(error) => warn!(%error, "cannot inspect thumbnail cache"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -715,33 +627,6 @@ mod tests {
|
||||
assert_eq!(generated.dimensions(), (720, 360));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_bounded_viewer_preview_without_upscaling() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let gallery = temp.path().join("gallery");
|
||||
fs::create_dir(&gallery).unwrap();
|
||||
image::RgbImage::new(5000, 2500)
|
||||
.save(gallery.join("wide.png"))
|
||||
.unwrap();
|
||||
let index = scan_gallery(&gallery, None).unwrap();
|
||||
let output = temp.path().join("preview.webp");
|
||||
generate_preview(&index.images[0], &output).unwrap();
|
||||
assert_eq!(image::open(output).unwrap().dimensions(), (2560, 1280));
|
||||
|
||||
image::RgbImage::new(400, 200)
|
||||
.save(gallery.join("small.png"))
|
||||
.unwrap();
|
||||
let index = scan_gallery(&gallery, None).unwrap();
|
||||
let small = index
|
||||
.images
|
||||
.iter()
|
||||
.find(|image| image.name == "small.png")
|
||||
.unwrap();
|
||||
let output = temp.path().join("small.webp");
|
||||
generate_preview(small, &output).unwrap();
|
||||
assert_eq!(image::open(output).unwrap().dimensions(), (400, 200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_promotes_without_duplicate_jobs() {
|
||||
let queue = JobQueue::default();
|
||||
@@ -749,9 +634,84 @@ mod tests {
|
||||
queue.push_background("image".to_owned());
|
||||
queue.push_urgent("image".to_owned());
|
||||
|
||||
let state = queue.state.lock().unwrap();
|
||||
let state = mutex_lock(&queue.state);
|
||||
assert_eq!(state.urgent.as_slices().0, ["image"]);
|
||||
assert!(state.background.is_empty());
|
||||
assert_eq!(state.queued.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_wakes_all_needed_workers() {
|
||||
let queue = Arc::new(JobQueue::default());
|
||||
let first_queue = Arc::clone(&queue);
|
||||
let second_queue = Arc::clone(&queue);
|
||||
let first = tokio::spawn(async move { first_queue.pop().await });
|
||||
let second = tokio::spawn(async move { second_queue.pop().await });
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
queue.push_background("first".to_owned());
|
||||
queue.push_background("second".to_owned());
|
||||
|
||||
let first = tokio::time::timeout(Duration::from_secs(1), first)
|
||||
.await
|
||||
.expect("first worker timed out")
|
||||
.unwrap();
|
||||
let second = tokio::time::timeout(Duration::from_secs(1), second)
|
||||
.await
|
||||
.expect("second worker timed out")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
HashSet::from([first, second]),
|
||||
HashSet::from(["first".into(), "second".into()])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_thumbnail_can_be_retried() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let gallery = temp.path().join("gallery");
|
||||
fs::create_dir(&gallery).unwrap();
|
||||
let image_path = gallery.join("image.png");
|
||||
image::RgbImage::new(20, 10).save(&image_path).unwrap();
|
||||
let index = scan_gallery(&gallery, None).unwrap();
|
||||
fs::write(&image_path, "invalid image").unwrap();
|
||||
|
||||
let manager = ThumbnailManager::new(temp.path().join("cache")).unwrap();
|
||||
manager.reconcile(&index.images);
|
||||
manager.start_workers(1);
|
||||
let id = &index.images[0].id;
|
||||
assert!(matches!(
|
||||
manager.ensure_ready(id).await,
|
||||
Err(ThumbnailError::Generation(_))
|
||||
));
|
||||
|
||||
image::RgbImage::new(20, 10).save(&image_path).unwrap();
|
||||
let thumbnail = manager.ensure_ready(id).await.unwrap();
|
||||
assert!(valid_cache_file(&thumbnail));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_an_entry_releases_waiters() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let gallery = temp.path().join("gallery");
|
||||
fs::create_dir(&gallery).unwrap();
|
||||
image::RgbImage::new(20, 10)
|
||||
.save(gallery.join("image.png"))
|
||||
.unwrap();
|
||||
let index = scan_gallery(&gallery, None).unwrap();
|
||||
let manager = ThumbnailManager::new(temp.path().join("cache")).unwrap();
|
||||
manager.reconcile(&index.images);
|
||||
|
||||
let id = index.images[0].id.clone();
|
||||
let waiting_manager = Arc::clone(&manager);
|
||||
let waiter = tokio::spawn(async move { waiting_manager.ensure_ready(&id).await });
|
||||
tokio::task::yield_now().await;
|
||||
manager.reconcile(&[]);
|
||||
|
||||
let result = tokio::time::timeout(Duration::from_secs(1), waiter)
|
||||
.await
|
||||
.expect("thumbnail waiter timed out")
|
||||
.expect("thumbnail waiter stopped");
|
||||
assert!(matches!(result, Err(ThumbnailError::NotFound)));
|
||||
}
|
||||
}
|
||||
+121
-79
@@ -16,7 +16,7 @@ use tracing::error;
|
||||
|
||||
use crate::{
|
||||
gallery::{Album, GalleryIndex, ImageRecord},
|
||||
thumbs::{ThumbnailManager, ThumbnailStatus, ViewerPreviewManager},
|
||||
thumbs::{ThumbnailError, ThumbnailManager, ThumbnailStatus},
|
||||
};
|
||||
|
||||
const INDEX_HTML: &[u8] = include_bytes!(concat!(
|
||||
@@ -36,7 +36,6 @@ const APP_CSS: &[u8] = include_bytes!(concat!(
|
||||
pub struct AppState {
|
||||
pub index: Arc<RwLock<GalleryIndex>>,
|
||||
pub thumbnails: Arc<ThumbnailManager>,
|
||||
pub previews: Arc<ViewerPreviewManager>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -44,16 +43,29 @@ pub struct AppState {
|
||||
struct ImagesQuery {
|
||||
album: Option<String>,
|
||||
search: Option<String>,
|
||||
sort: Option<String>,
|
||||
#[serde(default)]
|
||||
sort: ImageSort,
|
||||
seed: Option<String>,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
enum ImageSort {
|
||||
#[default]
|
||||
NameAsc,
|
||||
NameDesc,
|
||||
Newest,
|
||||
Explore,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GallerySummary {
|
||||
total: usize,
|
||||
albums: Vec<Album>,
|
||||
revision: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -78,8 +90,6 @@ struct ImageView {
|
||||
size: u64,
|
||||
modified_ms: u64,
|
||||
thumbnail_url: String,
|
||||
preview_url: String,
|
||||
original_url: String,
|
||||
}
|
||||
|
||||
impl From<&ImageRecord> for ImageView {
|
||||
@@ -94,8 +104,6 @@ impl From<&ImageRecord> for ImageView {
|
||||
size: record.size,
|
||||
modified_ms: record.modified_ms,
|
||||
thumbnail_url: format!("/api/images/{}/thumbnail", record.id),
|
||||
preview_url: format!("/api/images/{}/preview", record.id),
|
||||
original_url: format!("/api/images/{}/original", record.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,8 +115,6 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/images", get(images))
|
||||
.route("/api/status", get(thumbnail_status))
|
||||
.route("/api/images/{id}/thumbnail", get(thumbnail))
|
||||
.route("/api/images/{id}/preview", get(preview))
|
||||
.route("/api/images/{id}/original", get(original))
|
||||
.route("/assets/app.js", get(app_js))
|
||||
.route("/assets/app.css", get(app_css))
|
||||
.fallback(frontend)
|
||||
@@ -126,6 +132,7 @@ async fn gallery_summary(State(state): State<AppState>) -> Json<GallerySummary>
|
||||
Json(GallerySummary {
|
||||
total: index.images.len(),
|
||||
albums: index.albums.clone(),
|
||||
revision: index.revision.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,7 +140,7 @@ async fn images(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ImagesQuery>,
|
||||
) -> Json<ImagesResponse> {
|
||||
let index = state.index.read().await;
|
||||
let records = state.index.read().await.images.clone();
|
||||
let search = query
|
||||
.search
|
||||
.as_deref()
|
||||
@@ -142,9 +149,8 @@ async fn images(
|
||||
.map(str::to_lowercase);
|
||||
let album = query.album.as_deref().filter(|album| !album.is_empty());
|
||||
|
||||
let mut matches: Vec<&Arc<ImageRecord>> = index
|
||||
.images
|
||||
.iter()
|
||||
let mut matches: Vec<Arc<ImageRecord>> = records
|
||||
.into_iter()
|
||||
.filter(|record| album.is_none_or(|album| record.album == album))
|
||||
.filter(|record| {
|
||||
search
|
||||
@@ -153,10 +159,15 @@ async fn images(
|
||||
})
|
||||
.collect();
|
||||
|
||||
match query.sort.as_deref() {
|
||||
Some("name-desc") => matches.reverse(),
|
||||
Some("newest") => matches.sort_by_key(|record| Reverse(record.modified_ms)),
|
||||
_ => {}
|
||||
match query.sort {
|
||||
ImageSort::NameAsc => {}
|
||||
ImageSort::NameDesc => matches.reverse(),
|
||||
ImageSort::Newest => matches.sort_by_key(|record| Reverse(record.modified_ms)),
|
||||
ImageSort::Explore => {
|
||||
let seed_key =
|
||||
*blake3::hash(query.seed.as_deref().unwrap_or_default().as_bytes()).as_bytes();
|
||||
matches.sort_by_cached_key(|record| exploration_rank(&seed_key, &record.id));
|
||||
}
|
||||
}
|
||||
|
||||
let total = matches.len();
|
||||
@@ -177,6 +188,10 @@ async fn images(
|
||||
})
|
||||
}
|
||||
|
||||
fn exploration_rank(seed_key: &[u8; 32], image_id: &str) -> [u8; 32] {
|
||||
*blake3::keyed_hash(seed_key, image_id.as_bytes()).as_bytes()
|
||||
}
|
||||
|
||||
async fn thumbnail_status(State(state): State<AppState>) -> Json<ThumbnailStatus> {
|
||||
Json(state.thumbnails.status())
|
||||
}
|
||||
@@ -186,13 +201,14 @@ async fn thumbnail(
|
||||
Path(id): Path<String>,
|
||||
request: Request,
|
||||
) -> Result<Response, StatusCode> {
|
||||
if state.index.read().await.get(&id).is_none() {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
let path = state.thumbnails.ensure_ready(&id).await.map_err(|error| {
|
||||
error!(%id, %error, "cannot serve thumbnail");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
let path = match state.thumbnails.ensure_ready(&id).await {
|
||||
Ok(path) => path,
|
||||
Err(ThumbnailError::NotFound) => return Err(StatusCode::NOT_FOUND),
|
||||
Err(error) => {
|
||||
error!(%id, %error, "cannot serve thumbnail");
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
};
|
||||
serve_file(
|
||||
path,
|
||||
request,
|
||||
@@ -202,47 +218,6 @@ async fn thumbnail(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn preview(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
request: Request,
|
||||
) -> Result<Response, StatusCode> {
|
||||
if state.index.read().await.get(&id).is_none() {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
let path = state.previews.ensure_ready(&id).await.map_err(|error| {
|
||||
error!(%id, %error, "cannot serve viewer preview");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
serve_file(
|
||||
path,
|
||||
request,
|
||||
&format!("\"preview-{id}\""),
|
||||
"public, max-age=31536000, immutable",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn original(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
request: Request,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let record = state
|
||||
.index
|
||||
.read()
|
||||
.await
|
||||
.get(&id)
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
serve_file(
|
||||
record.path.clone(),
|
||||
request,
|
||||
&format!("\"image-{id}\""),
|
||||
"private, max-age=3600",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn serve_file(
|
||||
path: std::path::PathBuf,
|
||||
request: Request,
|
||||
@@ -291,33 +266,31 @@ fn add_cache_headers(
|
||||
}
|
||||
|
||||
async fn app_js() -> Response {
|
||||
embedded(APP_JS, "text/javascript; charset=utf-8", true)
|
||||
embedded(APP_JS, "text/javascript; charset=utf-8")
|
||||
}
|
||||
|
||||
async fn app_css() -> Response {
|
||||
embedded(APP_CSS, "text/css; charset=utf-8", true)
|
||||
embedded(APP_CSS, "text/css; charset=utf-8")
|
||||
}
|
||||
|
||||
async fn frontend(request: Request) -> Response {
|
||||
if request.uri().path() != "/" {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
if request.method() != Method::GET && request.method() != Method::HEAD {
|
||||
return StatusCode::METHOD_NOT_ALLOWED.into_response();
|
||||
}
|
||||
embedded(INDEX_HTML, "text/html; charset=utf-8", false)
|
||||
embedded(INDEX_HTML, "text/html; charset=utf-8")
|
||||
}
|
||||
|
||||
fn embedded(content: &'static [u8], content_type: &'static str, immutable: bool) -> Response {
|
||||
fn embedded(content: &'static [u8], content_type: &'static str) -> Response {
|
||||
let mut response = Response::new(Body::from(content));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static(if immutable {
|
||||
"public, max-age=31536000, immutable"
|
||||
} else {
|
||||
"no-cache"
|
||||
}),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
response.headers_mut().insert(
|
||||
header::X_CONTENT_TYPE_OPTIONS,
|
||||
HeaderValue::from_static("nosniff"),
|
||||
@@ -329,6 +302,22 @@ fn embedded(content: &'static [u8], content_type: &'static str, immutable: bool)
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn empty_router() -> Router {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let thumbnails = ThumbnailManager::new(temp.path().to_owned()).unwrap();
|
||||
router(AppState {
|
||||
index: Arc::new(RwLock::new(GalleryIndex::default())),
|
||||
thumbnails,
|
||||
})
|
||||
}
|
||||
|
||||
async fn request(path: &str) -> Response {
|
||||
empty_router()
|
||||
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_view_uses_versioned_id_urls() {
|
||||
let record = ImageRecord {
|
||||
@@ -345,7 +334,60 @@ mod tests {
|
||||
};
|
||||
let view = ImageView::from(&record);
|
||||
assert_eq!(view.thumbnail_url, "/api/images/abc/thumbnail");
|
||||
assert_eq!(view.preview_url, "/api/images/abc/preview");
|
||||
assert_eq!(view.original_url, "/api/images/abc/original");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exploration_rank_is_stable_and_seeded() {
|
||||
let first_seed = *blake3::hash(b"first-queue").as_bytes();
|
||||
let second_seed = *blake3::hash(b"second-queue").as_bytes();
|
||||
|
||||
assert_eq!(
|
||||
exploration_rank(&first_seed, "image-1"),
|
||||
exploration_rank(&first_seed, "image-1")
|
||||
);
|
||||
assert_ne!(
|
||||
exploration_rank(&first_seed, "image-1"),
|
||||
exploration_rank(&second_seed, "image-1")
|
||||
);
|
||||
assert_ne!(
|
||||
exploration_rank(&first_seed, "image-1"),
|
||||
exploration_rank(&first_seed, "image-2")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_unknown_sort_modes() {
|
||||
assert_eq!(
|
||||
request("/api/images?sort=name-asc").await.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
request("/api/images?sort=explore&seed=test").await.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
request("/api/images?sort=unexpected").await.status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_api_and_thumbnail_routes_return_not_found() {
|
||||
assert_eq!(
|
||||
request("/api/does-not-exist").await.status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
request("/api/images/missing/thumbnail").await.status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
assert_eq!(request("/missing").await.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fixed_asset_names_are_revalidated() {
|
||||
let response = request("/assets/app.js").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user