Initial import
This commit is contained in:
commit
02516abfd9
24 files changed
+8682
No files matched your search
@@ -0,0 +1,7 @@
|
||||
/pic/
|
||||
/.pixhelf-cache/
|
||||
/target/
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
*.log
|
||||
|
||||
Generated
+1110
File diff suppressed because it is too large.
Load diff
+29
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "pixhelf"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.104"
|
||||
axum = { version = "0.8.9", features = ["json", "query", "tokio"] }
|
||||
blake3 = "1.8.7"
|
||||
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
mime_guess = "2.0.5"
|
||||
natord = "1.0.9"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
tokio = { version = "1.53.1", features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
tower-http = { version = "0.6.11", features = ["compression-gzip", "fs", "set-header", "trace"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
walkdir = "2.5.0"
|
||||
webp = "0.3.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = "symbols"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Pixhelf
|
||||
|
||||
基于 Axum 和 React 的自托管图片画廊。React 生产资源会嵌入 Rust 可执行文件,运行时只需要一个程序文件和外部图库目录。
|
||||
|
||||
## 运行
|
||||
|
||||
默认从当前工作目录的 `./pic` 读取图片,并监听 `0.0.0.0:3002`:
|
||||
|
||||
```bash
|
||||
./target/release/pixhelf
|
||||
```
|
||||
|
||||
首次启动会先生成首批 60 张、最长边 720px 的 WebP 缩略图,然后开放 HTTP 服务。其余缩略图由后台任务持续处理;打开图片时,查看器会按需生成独立的最长边 2560px WebP 预览。新增、修改或删除图片会在定期扫描后自动同步。
|
||||
|
||||
缩略图和查看器预览缓存在 `./.pixhelf-cache/thumbnails` 下的版本化目录,原图不会被修改。
|
||||
|
||||
## 构建
|
||||
|
||||
需要当前稳定版 Rust、Node.js 20+ 和 npm:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
cd ..
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
`cargo build` 会自动执行 React 生产构建,并将结果嵌入 `target/release/pixhelf`。
|
||||
|
||||
## 参数
|
||||
|
||||
```text
|
||||
--gallery-dir PATH 图库目录,默认 ./pic
|
||||
--cache-dir PATH 缩略图和查看器预览缓存,默认 ./.pixhelf-cache/thumbnails
|
||||
--listen HOST:PORT 监听地址,默认 0.0.0.0:3002
|
||||
--initial-batch N 启动前生成数量,默认 60
|
||||
--workers N 后台压缩并发数,默认按 CPU 自动选择 2-4
|
||||
--scan-interval SEC 图库重新扫描间隔,默认 10 秒
|
||||
```
|
||||
|
||||
支持 JPEG、PNG 和 WebP 文件。其他文件不会进入图库索引。
|
||||
@@ -0,0 +1,29 @@
|
||||
use std::{env, path::PathBuf, process::Command};
|
||||
|
||||
fn main() {
|
||||
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest directory"));
|
||||
let frontend_dir = manifest_dir.join("frontend");
|
||||
|
||||
for path in [
|
||||
"frontend/index.html",
|
||||
"frontend/package.json",
|
||||
"frontend/package-lock.json",
|
||||
"frontend/tsconfig.json",
|
||||
"frontend/vite.config.ts",
|
||||
"frontend/src",
|
||||
] {
|
||||
println!("cargo:rerun-if-changed={path}");
|
||||
}
|
||||
|
||||
if env::var_os("PIXHELF_SKIP_FRONTEND_BUILD").is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let status = Command::new("npm")
|
||||
.args(["run", "build", "--silent"])
|
||||
.current_dir(frontend_dir)
|
||||
.status()
|
||||
.expect("failed to run the frontend build; install Node.js and run `npm ci` in frontend/");
|
||||
|
||||
assert!(status.success(), "frontend build failed");
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<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="theme-color" content="#f4f5f4" />
|
||||
<meta name="description" content="Pixhelf 私人图片画廊" />
|
||||
<title>Pixhelf</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1907
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "pixhelf-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"visual-check": "node scripts/visual-check.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.539.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"playwright-core": "^1.62.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
const executablePath =
|
||||
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ??
|
||||
"/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome";
|
||||
const baseUrl = process.env.PIXHELF_URL ?? "http://127.0.0.1:3002";
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath,
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
|
||||
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 },
|
||||
{ name: "mobile", width: 390, height: 844 },
|
||||
]) {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: target.width, height: target.height },
|
||||
deviceScaleFactor: target.name === "mobile" ? 3 : 1,
|
||||
hasTouch: target.name === "mobile",
|
||||
});
|
||||
const browserErrors = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") browserErrors.push(message.text());
|
||||
});
|
||||
page.on("pageerror", (error) => browserErrors.push(error.message));
|
||||
|
||||
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
await page.waitForSelector("[data-image-id]", { timeout: 60_000 });
|
||||
await page.waitForFunction(() => {
|
||||
const visible = [...document.querySelectorAll("[data-image-id] img")].filter((image) => {
|
||||
const rect = image.getBoundingClientRect();
|
||||
return rect.bottom > 0 && rect.top < innerHeight;
|
||||
});
|
||||
return visible.length >= 8 && 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,
|
||||
}));
|
||||
|
||||
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` });
|
||||
|
||||
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);
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
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;
|
||||
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.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 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 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
(previous) => document.querySelector(".viewer-counter")?.textContent !== previous,
|
||||
touchCounterBefore,
|
||||
);
|
||||
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,
|
||||
};
|
||||
} else {
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForSelector(".image-viewer", { state: "detached" });
|
||||
}
|
||||
|
||||
const result = {
|
||||
name: target.name,
|
||||
...layout,
|
||||
viewerLayout,
|
||||
zoomedLayout,
|
||||
doubleClickZoom,
|
||||
wheelZoom,
|
||||
singleClickControls,
|
||||
counterBefore,
|
||||
counterAfter,
|
||||
touchChecks,
|
||||
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
|
||||
)) ||
|
||||
browserErrors.length
|
||||
) {
|
||||
throw new Error(`visual check failed: ${JSON.stringify(result)}`);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
@@ -0,0 +1,627 @@
|
||||
import {
|
||||
ArrowDownAZ,
|
||||
Check,
|
||||
Folder,
|
||||
Image as ImageIcon,
|
||||
Images,
|
||||
LoaderCircle,
|
||||
Menu,
|
||||
PanelLeftClose,
|
||||
RefreshCw,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { getGallery, getImages, getStatus } from "./api";
|
||||
import ImageViewer from "./ImageViewer";
|
||||
import type {
|
||||
GalleryImage,
|
||||
GallerySummary,
|
||||
SortMode,
|
||||
ThumbnailStatus,
|
||||
} from "./types";
|
||||
|
||||
const PAGE_SIZE = 60;
|
||||
const MOBILE_PAGE_SIZE = 36;
|
||||
|
||||
function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() =>
|
||||
typeof window !== "undefined" && window.matchMedia(query).matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
const update = () => setMatches(media.matches);
|
||||
update();
|
||||
media.addEventListener("change", update);
|
||||
return () => media.removeEventListener("change", update);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function useDebounced<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebounced(value), delay);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [delay, value]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function formatCount(value: number): string {
|
||||
return new Intl.NumberFormat("zh-CN").format(value);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [summary, setSummary] = useState<GallerySummary | null>(null);
|
||||
const [status, setStatus] = useState<ThumbnailStatus | null>(null);
|
||||
const [images, setImages] = useState<GalleryImage[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [nextOffset, setNextOffset] = useState<number | null>(null);
|
||||
const [album, setAlbum] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [sort, setSort] = useState<SortMode>("name-asc");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const compactLayout = useMediaQuery("(max-width: 720px)");
|
||||
const pageSize = compactLayout ? MOBILE_PAGE_SIZE : PAGE_SIZE;
|
||||
const debouncedSearch = useDebounced(search.trim(), 250);
|
||||
const activeQuery = `${album}\u0000${debouncedSearch}\u0000${sort}`;
|
||||
const activeQueryRef = useRef(activeQuery);
|
||||
activeQueryRef.current = activeQuery;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
}, [album, debouncedSearch, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileNavOpen) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setMobileNavOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [mobileNavOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!compactLayout && mobileNavOpen) setMobileNavOpen(false);
|
||||
}, [compactLayout, mobileNavOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
getGallery(controller.signal)
|
||||
.then(setSummary)
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(reason instanceof Error ? reason.message : "无法读取图库");
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [reloadToken]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer = 0;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = await getStatus();
|
||||
if (cancelled) return;
|
||||
setStatus(next);
|
||||
timer = window.setTimeout(poll, next.backgroundComplete ? 10000 : 1500);
|
||||
} catch {
|
||||
if (!cancelled) timer = window.setTimeout(poll, 5000);
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status && summary && status.total !== summary.total) {
|
||||
void getGallery().then(setSummary).catch(() => undefined);
|
||||
}
|
||||
}, [status?.total, summary?.total]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setImages([]);
|
||||
setNextOffset(null);
|
||||
getImages(
|
||||
{
|
||||
album,
|
||||
search: debouncedSearch,
|
||||
sort,
|
||||
offset: 0,
|
||||
limit: pageSize,
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
.then((page) => {
|
||||
setImages(page.items);
|
||||
setTotal(page.total);
|
||||
setNextOffset(page.nextOffset);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(reason instanceof Error ? reason.message : "无法读取图片");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [album, debouncedSearch, pageSize, reloadToken, sort]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (nextOffset === null || loadingMore) return;
|
||||
const queryAtStart = activeQueryRef.current;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await getImages({
|
||||
album,
|
||||
search: debouncedSearch,
|
||||
sort,
|
||||
offset: nextOffset,
|
||||
limit: pageSize,
|
||||
});
|
||||
if (queryAtStart !== activeQueryRef.current) return;
|
||||
setImages((current) => [...current, ...page.items]);
|
||||
setNextOffset(page.nextOffset);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "无法继续加载图片");
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [album, debouncedSearch, loadingMore, nextOffset, pageSize, sort]);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel || nextOffset === null) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) void loadMore();
|
||||
},
|
||||
{ rootMargin: compactLayout ? "280px 0px" : "600px 0px" },
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [compactLayout, loadMore, nextOffset]);
|
||||
|
||||
const activeAlbum = summary?.albums.find((item) => item.path === album);
|
||||
const title = activeAlbum?.name ?? "全部图片";
|
||||
const selectedIndex = selectedId
|
||||
? images.findIndex((image) => image.id === selectedId)
|
||||
: -1;
|
||||
|
||||
const openViewer = useCallback((id: string) => {
|
||||
setSelectedId(id);
|
||||
}, []);
|
||||
|
||||
const closeViewer = useCallback(() => {
|
||||
setSelectedId(null);
|
||||
}, []);
|
||||
|
||||
const chooseAlbum = (path: string) => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
setAlbum(path);
|
||||
setMobileNavOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Header
|
||||
status={status}
|
||||
onOpenNavigation={() => setMobileNavOpen(true)}
|
||||
navigationOpen={mobileNavOpen}
|
||||
/>
|
||||
<Sidebar
|
||||
summary={summary}
|
||||
activeAlbum={album}
|
||||
onChoose={chooseAlbum}
|
||||
mobileOpen={mobileNavOpen}
|
||||
onClose={() => setMobileNavOpen(false)}
|
||||
status={status}
|
||||
/>
|
||||
|
||||
<main className="content">
|
||||
<section className="content-heading">
|
||||
<div className="title-block">
|
||||
<p className="path-label">{album || "图库"}</p>
|
||||
<div className="title-line">
|
||||
<h1>{title}</h1>
|
||||
<span>{formatCount(total)} 张</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<label className="search-field">
|
||||
<Search aria-hidden="true" size={17} />
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="搜索文件名"
|
||||
aria-label="搜索文件名"
|
||||
enterKeyHint="search"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
className="clear-search"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label="清空搜索"
|
||||
title="清空搜索"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
<label className="sort-control">
|
||||
<ArrowDownAZ aria-hidden="true" size={17} />
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(event) => setSort(event.target.value as SortMode)}
|
||||
aria-label="图片排序"
|
||||
>
|
||||
<option value="name-asc">名称顺序</option>
|
||||
<option value="name-desc">名称倒序</option>
|
||||
<option value="newest">最近修改</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<div className="error-banner" role="alert">
|
||||
<span>{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReloadToken((value) => value + 1)}
|
||||
aria-label="重新加载"
|
||||
title="重新加载"
|
||||
>
|
||||
<RefreshCw size={17} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<GallerySkeleton />
|
||||
) : images.length ? (
|
||||
<MasonryGallery images={images} onOpen={openViewer} />
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<ImageIcon size={30} strokeWidth={1.6} />
|
||||
<strong>没有找到图片</strong>
|
||||
<span>请调整相册或搜索条件</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} className="load-sentinel" aria-live="polite">
|
||||
{loadingMore && <LoaderCircle className="spin" size={21} aria-label="加载更多" />}
|
||||
{!loading && nextOffset === null && images.length > 0 && (
|
||||
<span>已显示全部 {formatCount(total)} 张图片</span>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{selectedIndex >= 0 && (
|
||||
<ImageViewer
|
||||
images={images}
|
||||
index={selectedIndex}
|
||||
total={total}
|
||||
onIndexChange={(index) => setSelectedId(images[index]?.id ?? null)}
|
||||
onNeedMore={() => void loadMore()}
|
||||
onClose={closeViewer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({
|
||||
status,
|
||||
onOpenNavigation,
|
||||
navigationOpen,
|
||||
}: {
|
||||
status: ThumbnailStatus | null;
|
||||
onOpenNavigation: () => void;
|
||||
navigationOpen: boolean;
|
||||
}) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button mobile-menu"
|
||||
onClick={onOpenNavigation}
|
||||
aria-label="打开相册导航"
|
||||
aria-expanded={navigationOpen}
|
||||
aria-controls="mobile-album-navigation"
|
||||
title="打开相册导航"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="brand">
|
||||
<span className="brand-mark"><Images size={21} /></span>
|
||||
<span>Pixhelf</span>
|
||||
</div>
|
||||
<StatusCompact status={status} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCompact({ status }: { status: ThumbnailStatus | null }) {
|
||||
if (!status) return <span className="status-compact muted">连接中</span>;
|
||||
const complete = status.backgroundComplete;
|
||||
return (
|
||||
<div
|
||||
className={`status-compact ${complete ? "complete" : ""}`}
|
||||
title={complete ? "缩略图处理完成" : "正在后台处理缩略图"}
|
||||
>
|
||||
{complete ? <Check size={15} /> : <LoaderCircle className="spin" size={15} />}
|
||||
<span>{formatCount(status.ready)} / {formatCount(status.total)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
summary,
|
||||
activeAlbum,
|
||||
onChoose,
|
||||
mobileOpen,
|
||||
onClose,
|
||||
status,
|
||||
}: {
|
||||
summary: GallerySummary | null;
|
||||
activeAlbum: string;
|
||||
onChoose: (path: string) => void;
|
||||
mobileOpen: boolean;
|
||||
onClose: () => void;
|
||||
status: ThumbnailStatus | null;
|
||||
}) {
|
||||
const navigation = (
|
||||
<>
|
||||
<div className="sidebar-heading">
|
||||
<span>相册</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button sidebar-close"
|
||||
onClick={onClose}
|
||||
aria-label="关闭相册导航"
|
||||
title="关闭相册导航"
|
||||
>
|
||||
<PanelLeftClose size={19} />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="album-nav" aria-label="相册">
|
||||
<AlbumButton
|
||||
label="全部图片"
|
||||
count={summary?.total ?? 0}
|
||||
active={!activeAlbum}
|
||||
onClick={() => onChoose("")}
|
||||
all
|
||||
/>
|
||||
{summary?.albums.map((item) => (
|
||||
<AlbumButton
|
||||
key={item.path}
|
||||
label={item.name}
|
||||
detail={item.path.includes("/") ? item.path : undefined}
|
||||
count={item.count}
|
||||
active={activeAlbum === item.path}
|
||||
onClick={() => onChoose(item.path)}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
<SidebarProgress status={status} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="sidebar desktop-sidebar">{navigation}</aside>
|
||||
{mobileOpen && (
|
||||
<div className="mobile-nav-layer" role="presentation">
|
||||
<button
|
||||
className="mobile-nav-scrim"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭相册导航"
|
||||
/>
|
||||
<aside
|
||||
id="mobile-album-navigation"
|
||||
className="sidebar mobile-sidebar"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="相册导航"
|
||||
>
|
||||
{navigation}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AlbumButton({
|
||||
label,
|
||||
detail,
|
||||
count,
|
||||
active,
|
||||
onClick,
|
||||
all = false,
|
||||
}: {
|
||||
label: string;
|
||||
detail?: string;
|
||||
count: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
all?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`album-link ${active ? "active" : ""}`}
|
||||
onClick={onClick}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{all ? <Images size={17} /> : <Folder size={17} />}
|
||||
<span className="album-copy">
|
||||
<strong>{label}</strong>
|
||||
{detail && <small>{detail}</small>}
|
||||
</span>
|
||||
<span className="album-count">{formatCount(count)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarProgress({ status }: { status: ThumbnailStatus | null }) {
|
||||
if (!status) return null;
|
||||
const percent = status.total ? Math.round((status.ready / status.total) * 100) : 100;
|
||||
return (
|
||||
<div className="sidebar-progress">
|
||||
<div>
|
||||
<span>{status.backgroundComplete ? "缩略图已就绪" : "后台处理中"}</span>
|
||||
<strong>{percent}%</strong>
|
||||
</div>
|
||||
<progress max={status.total || 1} value={status.ready} />
|
||||
{status.failed > 0 && <small>{status.failed} 个任务失败</small>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useColumnCount(ref: RefObject<HTMLDivElement | null>): number {
|
||||
const [columns, setColumns] = useState(5);
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
const update = (width: number) => {
|
||||
const minimumCardWidth = 218;
|
||||
const gap = 6;
|
||||
const next = Math.floor((width + gap) / (minimumCardWidth + gap));
|
||||
setColumns(Math.min(6, Math.max(2, next)));
|
||||
};
|
||||
update(element.clientWidth);
|
||||
const observer = new ResizeObserver(([entry]) => update(entry.contentRect.width));
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [ref]);
|
||||
return columns;
|
||||
}
|
||||
|
||||
function MasonryGallery({
|
||||
images,
|
||||
onOpen,
|
||||
}: {
|
||||
images: GalleryImage[];
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const columnCount = useColumnCount(ref);
|
||||
const columns = useMemo(() => {
|
||||
const result: { image: GalleryImage; index: number }[][] = Array.from(
|
||||
{ length: columnCount },
|
||||
() => [],
|
||||
);
|
||||
const heights = Array(columnCount).fill(0) as number[];
|
||||
images.forEach((image, index) => {
|
||||
const target = heights.indexOf(Math.min(...heights));
|
||||
result[target].push({ image, index });
|
||||
heights[target] += image.height / Math.max(image.width, 1) + 0.05;
|
||||
});
|
||||
return result;
|
||||
}, [columnCount, images]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="masonry"
|
||||
style={{ "--columns": columnCount } as CSSProperties}
|
||||
>
|
||||
{columns.map((column, columnIndex) => (
|
||||
<div className="masonry-column" key={columnIndex}>
|
||||
{column.map(({ image, index }) => (
|
||||
<ImageCard key={image.id} image={image} index={index} onOpen={onOpen} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageCard({
|
||||
image,
|
||||
index,
|
||||
onOpen,
|
||||
}: {
|
||||
image: GalleryImage;
|
||||
index: number;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="image-card"
|
||||
onClick={() => onOpen(image.id)}
|
||||
aria-label={`查看 ${image.name}`}
|
||||
title={image.name}
|
||||
data-image-id={image.id}
|
||||
style={{ aspectRatio: `${image.width} / ${image.height}` }}
|
||||
>
|
||||
{!failed ? (
|
||||
<img
|
||||
src={image.thumbnailUrl}
|
||||
alt={image.name}
|
||||
loading={index < 10 ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
fetchPriority={index < 5 ? "high" : "auto"}
|
||||
className={loaded ? "loaded" : ""}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className="image-fallback"><ImageIcon size={24} /></span>
|
||||
)}
|
||||
<span className="image-name">{image.name}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function GallerySkeleton() {
|
||||
const ratios = [1.4, 0.72, 1, 1.55, 0.8, 1.2, 0.67, 1.35, 0.9, 1.6, 0.76, 1.1];
|
||||
return (
|
||||
<div className="skeleton-grid" aria-label="正在加载图库">
|
||||
{ratios.map((ratio, index) => (
|
||||
<span key={index} style={{ aspectRatio: String(ratio) }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,855 @@
|
||||
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,43 @@
|
||||
import type {
|
||||
GallerySummary,
|
||||
ImagesPage,
|
||||
SortMode,
|
||||
ThumbnailStatus,
|
||||
} from "./types";
|
||||
|
||||
async function getJson<T>(url: string, signal?: AbortSignal): Promise<T> {
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`请求失败 (${response.status})`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function getGallery(signal?: AbortSignal): Promise<GallerySummary> {
|
||||
return getJson<GallerySummary>("/api/gallery", signal);
|
||||
}
|
||||
|
||||
export function getStatus(signal?: AbortSignal): Promise<ThumbnailStatus> {
|
||||
return getJson<ThumbnailStatus>("/api/status", signal);
|
||||
}
|
||||
|
||||
export function getImages(
|
||||
options: {
|
||||
album: string;
|
||||
search: string;
|
||||
sort: SortMode;
|
||||
offset: number;
|
||||
limit: number;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<ImagesPage> {
|
||||
const params = new URLSearchParams({
|
||||
offset: String(options.offset),
|
||||
limit: String(options.limit),
|
||||
sort: options.sort,
|
||||
});
|
||||
if (options.album) params.set("album", options.album);
|
||||
if (options.search) params.set("search", options.search);
|
||||
return getJson<ImagesPage>(`/api/images?${params}`, signal);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,44 @@
|
||||
export interface Album {
|
||||
path: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface GallerySummary {
|
||||
total: number;
|
||||
albums: Album[];
|
||||
}
|
||||
|
||||
export interface GalleryImage {
|
||||
id: string;
|
||||
name: string;
|
||||
album: string;
|
||||
relativePath: string;
|
||||
width: number;
|
||||
height: number;
|
||||
size: number;
|
||||
modifiedMs: number;
|
||||
thumbnailUrl: string;
|
||||
previewUrl: string;
|
||||
originalUrl: string;
|
||||
}
|
||||
|
||||
export interface ImagesPage {
|
||||
items: GalleryImage[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
nextOffset: number | null;
|
||||
}
|
||||
|
||||
export interface ThumbnailStatus {
|
||||
total: number;
|
||||
ready: number;
|
||||
queued: number;
|
||||
processing: number;
|
||||
failed: number;
|
||||
initialBatchReady: boolean;
|
||||
backgroundComplete: boolean;
|
||||
}
|
||||
|
||||
export type SortMode = "name-asc" | "name-desc" | "newest";
|
||||
@@ -0,0 +1,826 @@
|
||||
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>;
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
target: "es2022",
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: "assets/app.js",
|
||||
chunkFileNames: "assets/[name].js",
|
||||
assetFileNames: (asset) =>
|
||||
asset.name?.endsWith(".css") ? "assets/app.css" : "assets/[name][extname]",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
use std::{env, net::SocketAddr, path::PathBuf, time::Duration};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub gallery_dir: PathBuf,
|
||||
pub cache_dir: PathBuf,
|
||||
pub listen: SocketAddr,
|
||||
pub initial_batch: usize,
|
||||
pub workers: usize,
|
||||
pub scan_interval: Duration,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env_and_args() -> Result<Self> {
|
||||
let cwd = env::current_dir().context("cannot determine the working directory")?;
|
||||
let mut gallery_dir = cwd.join("pic");
|
||||
let mut cache_dir = cwd.join(".pixhelf-cache").join("thumbnails");
|
||||
let mut listen = "0.0.0.0:3002".parse::<SocketAddr>()?;
|
||||
let mut initial_batch = 60usize;
|
||||
let mut workers = default_worker_count();
|
||||
let mut scan_interval = Duration::from_secs(10);
|
||||
|
||||
let mut args = env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--gallery-dir" => gallery_dir = path_value(&mut args, &arg, &cwd)?,
|
||||
"--cache-dir" => cache_dir = path_value(&mut args, &arg, &cwd)?,
|
||||
"--listen" => {
|
||||
listen = value(&mut args, &arg)?
|
||||
.parse()
|
||||
.with_context(|| "--listen must be in HOST:PORT form")?;
|
||||
}
|
||||
"--initial-batch" => {
|
||||
initial_batch = number_value(&mut args, &arg, 1, 500)?;
|
||||
}
|
||||
"--workers" => workers = number_value(&mut args, &arg, 1, 16)?,
|
||||
"--scan-interval" => {
|
||||
scan_interval =
|
||||
Duration::from_secs(number_value(&mut args, &arg, 2, 3600)? as u64);
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
print_help();
|
||||
std::process::exit(0);
|
||||
}
|
||||
unknown => bail!("unknown argument: {unknown}\n\nRun pixhelf --help for usage."),
|
||||
}
|
||||
}
|
||||
|
||||
if !gallery_dir.is_dir() {
|
||||
bail!(
|
||||
"gallery directory does not exist: {}",
|
||||
gallery_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(&cache_dir)
|
||||
.with_context(|| format!("cannot create cache directory: {}", cache_dir.display()))?;
|
||||
|
||||
Ok(Self {
|
||||
gallery_dir,
|
||||
cache_dir,
|
||||
listen,
|
||||
initial_batch,
|
||||
workers,
|
||||
scan_interval,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn value(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<String> {
|
||||
args.next()
|
||||
.with_context(|| format!("{flag} requires a value"))
|
||||
}
|
||||
|
||||
fn path_value(
|
||||
args: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
cwd: &std::path::Path,
|
||||
) -> Result<PathBuf> {
|
||||
let path = PathBuf::from(value(args, flag)?);
|
||||
Ok(if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
cwd.join(path)
|
||||
})
|
||||
}
|
||||
|
||||
fn number_value(
|
||||
args: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
min: usize,
|
||||
max: usize,
|
||||
) -> Result<usize> {
|
||||
let raw = value(args, flag)?;
|
||||
let parsed = raw
|
||||
.parse::<usize>()
|
||||
.with_context(|| format!("{flag} must be a number"))?;
|
||||
if !(min..=max).contains(&parsed) {
|
||||
bail!("{flag} must be between {min} and {max}");
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn default_worker_count() -> usize {
|
||||
std::thread::available_parallelism()
|
||||
.map(|parallelism| parallelism.get().clamp(2, 4))
|
||||
.unwrap_or(2)
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"pixhelf - a self-hosted image gallery\n\n\
|
||||
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 \
|
||||
--listen HOST:PORT Listen address (default: 0.0.0.0:3002)\n \
|
||||
--initial-batch N Thumbnails prepared before serving (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"
|
||||
);
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::UNIX_EPOCH,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use image::{ImageDecoder, ImageReader, metadata::Orientation};
|
||||
use serde::Serialize;
|
||||
use tracing::warn;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ImageRecord {
|
||||
pub id: String,
|
||||
pub path: PathBuf,
|
||||
pub relative_path: String,
|
||||
pub name: String,
|
||||
pub album: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub size: u64,
|
||||
pub modified_ms: u64,
|
||||
pub(crate) modified_ns: u128,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Album {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct GalleryIndex {
|
||||
pub images: Vec<Arc<ImageRecord>>,
|
||||
pub by_id: HashMap<String, Arc<ImageRecord>>,
|
||||
pub albums: Vec<Album>,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<GalleryIndex> {
|
||||
let previous_by_path: HashMap<&str, &Arc<ImageRecord>> = previous
|
||||
.into_iter()
|
||||
.flat_map(|index| index.images.iter())
|
||||
.map(|record| (record.relative_path.as_str(), record))
|
||||
.collect();
|
||||
|
||||
let mut images = Vec::new();
|
||||
for entry in WalkDir::new(root)
|
||||
.follow_links(false)
|
||||
.sort_by_file_name()
|
||||
.into_iter()
|
||||
.filter_map(|entry| match entry {
|
||||
Ok(entry) => Some(entry),
|
||||
Err(error) => {
|
||||
warn!(%error, "cannot inspect gallery entry");
|
||||
None
|
||||
}
|
||||
})
|
||||
{
|
||||
if !entry.file_type().is_file() || !is_supported_image(entry.path()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = entry.into_path();
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.with_context(|| format!("{} is outside the gallery root", path.display()))?;
|
||||
let relative_path = path_to_url(relative);
|
||||
let metadata = match fs::metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => {
|
||||
warn!(path = %path.display(), %error, "cannot read image metadata");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.unwrap_or(UNIX_EPOCH)
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let modified_ns = modified.as_nanos();
|
||||
let size = metadata.len();
|
||||
|
||||
if let Some(existing) = previous_by_path.get(relative_path.as_str())
|
||||
&& existing.size == size
|
||||
&& existing.modified_ns == modified_ns
|
||||
{
|
||||
images.push(Arc::clone(existing));
|
||||
continue;
|
||||
}
|
||||
|
||||
let (width, height) = match probe_dimensions(&path) {
|
||||
Ok(dimensions) => dimensions,
|
||||
Err(error) => {
|
||||
warn!(path = %path.display(), %error, "cannot decode image header");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let album = relative
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.map(path_to_url)
|
||||
.unwrap_or_default();
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(&relative_path)
|
||||
.to_owned();
|
||||
let id = image_id(&relative_path, size, modified_ns);
|
||||
|
||||
images.push(Arc::new(ImageRecord {
|
||||
id,
|
||||
path,
|
||||
relative_path,
|
||||
name,
|
||||
album,
|
||||
width,
|
||||
height,
|
||||
size,
|
||||
modified_ms: modified.as_millis().min(u64::MAX as u128) as u64,
|
||||
modified_ns,
|
||||
}));
|
||||
}
|
||||
|
||||
images.sort_by(|left, right| {
|
||||
natord::compare_ignore_case(&left.relative_path, &right.relative_path)
|
||||
});
|
||||
|
||||
let mut album_counts = BTreeMap::<String, usize>::new();
|
||||
for image in &images {
|
||||
*album_counts.entry(image.album.clone()).or_default() += 1;
|
||||
}
|
||||
let albums = album_counts
|
||||
.into_iter()
|
||||
.map(|(path, count)| Album {
|
||||
name: path
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or("根目录")
|
||||
.to_owned(),
|
||||
path,
|
||||
count,
|
||||
})
|
||||
.collect();
|
||||
let by_id = images
|
||||
.iter()
|
||||
.map(|record| (record.id.clone(), Arc::clone(record)))
|
||||
.collect();
|
||||
|
||||
Ok(GalleryIndex {
|
||||
images,
|
||||
by_id,
|
||||
albums,
|
||||
})
|
||||
}
|
||||
|
||||
fn probe_dimensions(path: &Path) -> Result<(u32, u32)> {
|
||||
let reader = ImageReader::open(path)?.with_guessed_format()?;
|
||||
let mut decoder = reader.into_decoder()?;
|
||||
let (width, height) = decoder.dimensions();
|
||||
let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
|
||||
Ok(oriented_dimensions(width, height, orientation))
|
||||
}
|
||||
|
||||
fn oriented_dimensions(width: u32, height: u32, orientation: Orientation) -> (u32, u32) {
|
||||
match orientation {
|
||||
Orientation::Rotate90
|
||||
| Orientation::Rotate270
|
||||
| Orientation::Rotate90FlipH
|
||||
| Orientation::Rotate270FlipH => (height, width),
|
||||
_ => (width, height),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_supported_image(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.map(|extension| {
|
||||
matches!(
|
||||
extension.to_ascii_lowercase().as_str(),
|
||||
"jpg" | "jpeg" | "png" | "webp"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn image_id(relative_path: &str, size: u64, modified_ns: u128) -> String {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(b"pixhelf-image-v1\0");
|
||||
hasher.update(relative_path.as_bytes());
|
||||
hasher.update(&size.to_le_bytes());
|
||||
hasher.update(&modified_ns.to_le_bytes());
|
||||
hasher.finalize().to_hex().to_string()
|
||||
}
|
||||
|
||||
fn path_to_url(path: &Path) -> String {
|
||||
path.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn swaps_dimensions_for_quarter_turns() {
|
||||
assert_eq!(
|
||||
oriented_dimensions(1200, 800, Orientation::Rotate90),
|
||||
(800, 1200)
|
||||
);
|
||||
assert_eq!(
|
||||
oriented_dimensions(1200, 800, Orientation::Rotate270FlipH),
|
||||
(800, 1200)
|
||||
);
|
||||
assert_eq!(
|
||||
oriented_dimensions(1200, 800, Orientation::Rotate180),
|
||||
(1200, 800)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scans_images_and_skips_other_files() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let album = temp.path().join("album");
|
||||
fs::create_dir(&album).unwrap();
|
||||
image::RgbImage::new(20, 10)
|
||||
.save(album.join("image.png"))
|
||||
.unwrap();
|
||||
fs::write(album.join("notes.tsv"), "ignored").unwrap();
|
||||
|
||||
let index = scan_gallery(temp.path(), None).unwrap();
|
||||
assert_eq!(index.images.len(), 1);
|
||||
assert_eq!(index.images[0].album, "album");
|
||||
assert_eq!((index.images[0].width, index.images[0].height), (20, 10));
|
||||
assert_eq!(index.albums[0].count, 1);
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
mod config;
|
||||
mod gallery;
|
||||
mod thumbs;
|
||||
mod web;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use config::Config;
|
||||
use gallery::scan_gallery;
|
||||
use thumbs::{ThumbnailManager, ViewerPreviewManager};
|
||||
use tokio::{net::TcpListener, sync::RwLock};
|
||||
use tracing::{error, info};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use web::AppState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("pixhelf=info,tower_http=info")),
|
||||
)
|
||||
.compact()
|
||||
.init();
|
||||
|
||||
let config = Config::from_env_and_args()?;
|
||||
info!(gallery = %config.gallery_dir.display(), "scanning gallery");
|
||||
let gallery_dir = config.gallery_dir.clone();
|
||||
let index = tokio::task::spawn_blocking(move || scan_gallery(&gallery_dir, None))
|
||||
.await
|
||||
.context("gallery scan task stopped")??;
|
||||
if index.images.is_empty() {
|
||||
bail!(
|
||||
"no supported images found in {}",
|
||||
config.gallery_dir.display()
|
||||
);
|
||||
}
|
||||
info!(
|
||||
images = index.images.len(),
|
||||
albums = index.albums.len(),
|
||||
"gallery indexed"
|
||||
);
|
||||
|
||||
let initial_ids: Vec<String> = index
|
||||
.images
|
||||
.iter()
|
||||
.take(config.initial_batch)
|
||||
.map(|record| record.id.clone())
|
||||
.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);
|
||||
tokio::spawn(async move {
|
||||
initial_thumbnail_manager
|
||||
.prepare_initial(&initial_ids)
|
||||
.await;
|
||||
});
|
||||
|
||||
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)
|
||||
.await
|
||||
.with_context(|| format!("cannot listen on {}", config.listen))?;
|
||||
info!(listen = %config.listen, "Pixhelf is ready");
|
||||
|
||||
axum::serve(listener, web::router(state))
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rescan_gallery(state: AppState, config: Config) {
|
||||
loop {
|
||||
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 updated = match scan {
|
||||
Ok(Ok(index)) => index,
|
||||
Ok(Err(error)) => {
|
||||
error!(%error, "gallery rescan failed");
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
error!(%error, "gallery rescan task stopped");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if previous.same_revision(&updated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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() {
|
||||
if let Err(error) = tokio::signal::ctrl_c().await {
|
||||
error!(%error, "cannot install shutdown handler");
|
||||
}
|
||||
}
|
||||
+757
@@ -0,0 +1,757 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet, VecDeque},
|
||||
fs::{self, File},
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc, Mutex, RwLock,
|
||||
atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use image::{
|
||||
DynamicImage, GenericImageView, ImageDecoder, ImageReader, imageops::FilterType,
|
||||
metadata::Orientation,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use tokio::sync::{Notify, Semaphore};
|
||||
use tracing::{info, warn};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::gallery::ImageRecord;
|
||||
|
||||
const THUMBNAIL_EDGE: u32 = 720;
|
||||
const WEBP_QUALITY: f32 = 82.0;
|
||||
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 {
|
||||
cache_root: PathBuf,
|
||||
entries: RwLock<HashMap<String, Arc<ThumbEntry>>>,
|
||||
queue: JobQueue,
|
||||
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>,
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ThumbEntry {
|
||||
record: Arc<ImageRecord>,
|
||||
cache_path: PathBuf,
|
||||
state: AtomicU8,
|
||||
urgent: AtomicBool,
|
||||
attempts: AtomicU8,
|
||||
last_error: Mutex<Option<String>>,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct JobQueue {
|
||||
state: Mutex<QueueState>,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct QueueState {
|
||||
urgent: VecDeque<String>,
|
||||
background: VecDeque<String>,
|
||||
queued: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThumbnailStatus {
|
||||
pub total: usize,
|
||||
pub ready: usize,
|
||||
pub queued: usize,
|
||||
pub processing: usize,
|
||||
pub failed: usize,
|
||||
pub initial_batch_ready: bool,
|
||||
pub background_complete: bool,
|
||||
}
|
||||
|
||||
impl ThumbnailManager {
|
||||
pub fn new(cache_dir: PathBuf) -> Result<Arc<Self>> {
|
||||
let cache_root = cache_dir.join(CACHE_VERSION);
|
||||
fs::create_dir_all(&cache_root)
|
||||
.with_context(|| format!("cannot create thumbnail cache: {}", cache_root.display()))?;
|
||||
purge_temporary_files(&cache_root);
|
||||
Ok(Arc::new(Self {
|
||||
cache_root,
|
||||
entries: RwLock::new(HashMap::new()),
|
||||
queue: JobQueue::default(),
|
||||
initial_ready: AtomicBool::new(false),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn reconcile(&self, records: &[Arc<ImageRecord>]) {
|
||||
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()));
|
||||
|
||||
for record in records {
|
||||
if entries.contains_key(&record.id) {
|
||||
continue;
|
||||
}
|
||||
let cache_path = self.cache_path(&record.id);
|
||||
let state = if valid_cache_file(&cache_path) {
|
||||
READY
|
||||
} else {
|
||||
missing.push(record.id.clone());
|
||||
PENDING
|
||||
};
|
||||
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),
|
||||
notify: Notify::new(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for id in missing {
|
||||
self.queue.push_background(id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_workers(self: &Arc<Self>, count: usize) {
|
||||
for worker in 0..count {
|
||||
let manager = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
manager.worker_loop(worker).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn prepare_initial(&self, ids: &[String]) {
|
||||
for id in ids {
|
||||
self.promote(id);
|
||||
}
|
||||
|
||||
let mut failures = 0usize;
|
||||
for id in ids {
|
||||
if let Err(error) = self.wait_for(id).await {
|
||||
failures += 1;
|
||||
warn!(%id, %error, "initial thumbnail failed");
|
||||
}
|
||||
}
|
||||
self.initial_ready.store(true, Ordering::Release);
|
||||
info!(
|
||||
requested = ids.len(),
|
||||
failures, "initial thumbnail batch is ready"
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn ensure_ready(&self, id: &str) -> Result<PathBuf> {
|
||||
self.promote(id);
|
||||
self.wait_for(id).await
|
||||
}
|
||||
|
||||
pub fn status(&self) -> ThumbnailStatus {
|
||||
let entries = self.entries.read().expect("thumbnail entries lock");
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
let total = entries.len();
|
||||
ThumbnailStatus {
|
||||
total,
|
||||
ready,
|
||||
queued,
|
||||
processing,
|
||||
failed,
|
||||
initial_batch_ready: self.initial_ready.load(Ordering::Acquire),
|
||||
background_complete: ready + failed == total && processing == 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cleanup_stale(&self) {
|
||||
let valid_ids: HashSet<String> = self
|
||||
.entries
|
||||
.read()
|
||||
.expect("thumbnail 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, "thumbnail cache cleanup task failed");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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")
|
||||
.get(id)
|
||||
.cloned()
|
||||
.with_context(|| format!("unknown image: {id}"))?;
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
FAILED => {
|
||||
let message = entry
|
||||
.last_error
|
||||
.lock()
|
||||
.expect("thumbnail error lock")
|
||||
.clone()
|
||||
.unwrap_or_else(|| "thumbnail generation failed".to_owned());
|
||||
bail!(message);
|
||||
}
|
||||
_ => notified.await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 Some(entry) = entry else {
|
||||
continue;
|
||||
};
|
||||
if entry
|
||||
.state
|
||||
.compare_exchange(PENDING, PROCESSING, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let record = Arc::clone(&entry.record);
|
||||
let output = entry.cache_path.clone();
|
||||
let result = tokio::task::spawn_blocking(move || generate_thumbnail(&record, &output))
|
||||
.await
|
||||
.map_err(|error| anyhow!("thumbnail worker stopped: {error}"))
|
||||
.and_then(|result| result);
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
entry.state.store(READY, Ordering::Release);
|
||||
entry.notify.notify_waiters();
|
||||
}
|
||||
Err(error) => {
|
||||
let attempt = entry.attempts.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
*entry.last_error.lock().expect("thumbnail error lock") =
|
||||
Some(error.to_string());
|
||||
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();
|
||||
self.schedule_retry(entry, id, attempt);
|
||||
} else {
|
||||
entry.state.store(FAILED, Ordering::Release);
|
||||
entry.notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_path(&self, id: &str) -> PathBuf {
|
||||
shard_path(&self.cache_root, id)
|
||||
}
|
||||
|
||||
fn schedule_retry(self: &Arc<Self>, entry: Arc<ThumbEntry>, id: String, attempt: u8) {
|
||||
let manager = Arc::clone(self);
|
||||
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) {
|
||||
manager.queue.push_urgent(id);
|
||||
} else {
|
||||
manager.queue.push_background(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl JobQueue {
|
||||
fn push_urgent(&self, id: String) {
|
||||
let should_notify = {
|
||||
let mut state = self.state.lock().expect("thumbnail queue lock");
|
||||
if state.queued.contains(&id) {
|
||||
if let Some(position) = state.background.iter().position(|queued| queued == &id) {
|
||||
state.background.remove(position);
|
||||
state.urgent.push_back(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
state.queued.insert(id.clone());
|
||||
state.urgent.push_back(id);
|
||||
true
|
||||
}
|
||||
};
|
||||
if !should_notify {
|
||||
return;
|
||||
}
|
||||
self.notify.notify_one();
|
||||
}
|
||||
|
||||
fn push_background(&self, id: String) {
|
||||
let should_notify = {
|
||||
let mut state = self.state.lock().expect("thumbnail queue lock");
|
||||
if state.queued.insert(id.clone()) {
|
||||
state.background.push_back(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if !should_notify {
|
||||
return;
|
||||
}
|
||||
self.notify.notify_one();
|
||||
}
|
||||
|
||||
async fn pop(&self) -> String {
|
||||
loop {
|
||||
let notified = self.notify.notified();
|
||||
if let Some(id) = {
|
||||
let mut state = self.state.lock().expect("thumbnail queue lock");
|
||||
let id = state
|
||||
.urgent
|
||||
.pop_front()
|
||||
.or_else(|| state.background.pop_front());
|
||||
if let Some(id) = &id {
|
||||
state.queued.remove(id);
|
||||
}
|
||||
id
|
||||
} {
|
||||
return id;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_cache_file(path: &Path) -> bool {
|
||||
fs::metadata(path)
|
||||
.map(|metadata| metadata.is_file() && metadata.len() > 20)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn shard_path(root: &Path, id: &str) -> PathBuf {
|
||||
let shard = id.get(..2).unwrap_or("00");
|
||||
root.join(shard).join(format!("{id}.webp"))
|
||||
}
|
||||
|
||||
fn generate_thumbnail(record: &ImageRecord, output: &Path) -> Result<()> {
|
||||
generate_image_cache(
|
||||
record,
|
||||
output,
|
||||
THUMBNAIL_EDGE,
|
||||
THUMBNAIL_FILTER,
|
||||
WEBP_QUALITY,
|
||||
"thumbnail",
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
max_edge: u32,
|
||||
filter: FilterType,
|
||||
quality: f32,
|
||||
label: &str,
|
||||
) -> Result<()> {
|
||||
let reader = ImageReader::open(&record.path)
|
||||
.with_context(|| format!("cannot open {}", record.path.display()))?
|
||||
.with_guessed_format()?;
|
||||
let mut decoder = reader.into_decoder()?;
|
||||
let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
|
||||
let mut image = DynamicImage::from_decoder(decoder)?;
|
||||
image.apply_orientation(orientation);
|
||||
let (width, height) = image.dimensions();
|
||||
let image = if width.max(height) > max_edge {
|
||||
image.resize(max_edge, max_edge, filter)
|
||||
} else {
|
||||
image
|
||||
};
|
||||
write_webp(&image, output, quality)
|
||||
.with_context(|| format!("cannot store {label}: {}", output.display()))
|
||||
}
|
||||
|
||||
fn write_webp(image: &DynamicImage, output: &Path, quality: f32) -> Result<()> {
|
||||
let encoded = match image {
|
||||
DynamicImage::ImageRgb8(rgb) => {
|
||||
webp::Encoder::from_rgb(rgb.as_raw(), rgb.width(), rgb.height()).encode(quality)
|
||||
}
|
||||
DynamicImage::ImageRgba8(rgba) => {
|
||||
webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height()).encode(quality)
|
||||
}
|
||||
_ => {
|
||||
let rgba = image.to_rgba8();
|
||||
webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height()).encode(quality)
|
||||
}
|
||||
};
|
||||
let parent = output
|
||||
.parent()
|
||||
.with_context(|| format!("invalid image cache path: {}", output.display()))?;
|
||||
fs::create_dir_all(parent)?;
|
||||
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let temporary = parent.join(format!(
|
||||
".{}.{}.{}.tmp",
|
||||
output
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("image"),
|
||||
std::process::id(),
|
||||
sequence
|
||||
));
|
||||
let write_result = (|| -> Result<()> {
|
||||
let mut file = File::create(&temporary)?;
|
||||
file.write_all(encoded.as_ref())?;
|
||||
file.flush()?;
|
||||
drop(file);
|
||||
fs::rename(&temporary, output)?;
|
||||
Ok(())
|
||||
})();
|
||||
if write_result.is_err() {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
}
|
||||
write_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;
|
||||
}
|
||||
let id = entry
|
||||
.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())
|
||||
{
|
||||
warn!(path = %entry.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())
|
||||
{
|
||||
warn!(path = %entry.path().display(), %error, "cannot remove temporary thumbnail");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gallery::scan_gallery;
|
||||
|
||||
#[test]
|
||||
fn generates_one_bounded_webp_thumbnail() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let gallery = temp.path().join("gallery");
|
||||
fs::create_dir(&gallery).unwrap();
|
||||
image::RgbImage::new(1200, 600)
|
||||
.save(gallery.join("wide.png"))
|
||||
.unwrap();
|
||||
let index = scan_gallery(&gallery, None).unwrap();
|
||||
let output = temp.path().join("thumbnail.webp");
|
||||
|
||||
generate_thumbnail(&index.images[0], &output).unwrap();
|
||||
let generated = image::open(output).unwrap();
|
||||
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();
|
||||
queue.push_background("image".to_owned());
|
||||
queue.push_background("image".to_owned());
|
||||
queue.push_urgent("image".to_owned());
|
||||
|
||||
let state = queue.state.lock().unwrap();
|
||||
assert_eq!(state.urgent.as_slices().0, ["image"]);
|
||||
assert!(state.background.is_empty());
|
||||
assert_eq!(state.queued.len(), 1);
|
||||
}
|
||||
}
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
use std::{cmp::Reverse, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::Body,
|
||||
extract::{Path, Query, Request, State},
|
||||
http::{HeaderValue, Method, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::RwLock;
|
||||
use tower::ServiceExt;
|
||||
use tower_http::{compression::CompressionLayer, services::ServeFile, trace::TraceLayer};
|
||||
use tracing::error;
|
||||
|
||||
use crate::{
|
||||
gallery::{Album, GalleryIndex, ImageRecord},
|
||||
thumbs::{ThumbnailManager, ThumbnailStatus, ViewerPreviewManager},
|
||||
};
|
||||
|
||||
const INDEX_HTML: &[u8] = include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/frontend/dist/index.html"
|
||||
));
|
||||
const APP_JS: &[u8] = include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/frontend/dist/assets/app.js"
|
||||
));
|
||||
const APP_CSS: &[u8] = include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/frontend/dist/assets/app.css"
|
||||
));
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub index: Arc<RwLock<GalleryIndex>>,
|
||||
pub thumbnails: Arc<ThumbnailManager>,
|
||||
pub previews: Arc<ViewerPreviewManager>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ImagesQuery {
|
||||
album: Option<String>,
|
||||
search: Option<String>,
|
||||
sort: Option<String>,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GallerySummary {
|
||||
total: usize,
|
||||
albums: Vec<Album>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ImagesResponse {
|
||||
items: Vec<ImageView>,
|
||||
total: usize,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
next_offset: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ImageView {
|
||||
id: String,
|
||||
name: String,
|
||||
album: String,
|
||||
relative_path: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
size: u64,
|
||||
modified_ms: u64,
|
||||
thumbnail_url: String,
|
||||
preview_url: String,
|
||||
original_url: String,
|
||||
}
|
||||
|
||||
impl From<&ImageRecord> for ImageView {
|
||||
fn from(record: &ImageRecord) -> Self {
|
||||
Self {
|
||||
id: record.id.clone(),
|
||||
name: record.name.clone(),
|
||||
album: record.album.clone(),
|
||||
relative_path: record.relative_path.clone(),
|
||||
width: record.width,
|
||||
height: record.height,
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/health", get(health))
|
||||
.route("/api/gallery", get(gallery_summary))
|
||||
.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)
|
||||
.layer(CompressionLayer::new())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
async fn gallery_summary(State(state): State<AppState>) -> Json<GallerySummary> {
|
||||
let index = state.index.read().await;
|
||||
Json(GallerySummary {
|
||||
total: index.images.len(),
|
||||
albums: index.albums.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn images(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ImagesQuery>,
|
||||
) -> Json<ImagesResponse> {
|
||||
let index = state.index.read().await;
|
||||
let search = query
|
||||
.search
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|search| !search.is_empty())
|
||||
.map(str::to_lowercase);
|
||||
let album = query.album.as_deref().filter(|album| !album.is_empty());
|
||||
|
||||
let mut matches: Vec<&Arc<ImageRecord>> = index
|
||||
.images
|
||||
.iter()
|
||||
.filter(|record| album.is_none_or(|album| record.album == album))
|
||||
.filter(|record| {
|
||||
search
|
||||
.as_ref()
|
||||
.is_none_or(|search| record.relative_path.to_lowercase().contains(search))
|
||||
})
|
||||
.collect();
|
||||
|
||||
match query.sort.as_deref() {
|
||||
Some("name-desc") => matches.reverse(),
|
||||
Some("newest") => matches.sort_by_key(|record| Reverse(record.modified_ms)),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let total = matches.len();
|
||||
let offset = query.offset.unwrap_or(0).min(total);
|
||||
let limit = query.limit.unwrap_or(60).clamp(1, 200);
|
||||
let end = offset.saturating_add(limit).min(total);
|
||||
let items = matches[offset..end]
|
||||
.iter()
|
||||
.map(|record| ImageView::from(record.as_ref()))
|
||||
.collect();
|
||||
|
||||
Json(ImagesResponse {
|
||||
items,
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
next_offset: (end < total).then_some(end),
|
||||
})
|
||||
}
|
||||
|
||||
async fn thumbnail_status(State(state): State<AppState>) -> Json<ThumbnailStatus> {
|
||||
Json(state.thumbnails.status())
|
||||
}
|
||||
|
||||
async fn thumbnail(
|
||||
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.thumbnails.ensure_ready(&id).await.map_err(|error| {
|
||||
error!(%id, %error, "cannot serve thumbnail");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
serve_file(
|
||||
path,
|
||||
request,
|
||||
&format!("\"thumb-{id}\""),
|
||||
"public, max-age=31536000, immutable",
|
||||
)
|
||||
.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,
|
||||
etag: &str,
|
||||
cache_control: &'static str,
|
||||
) -> Result<Response, StatusCode> {
|
||||
if request
|
||||
.headers()
|
||||
.get(header::IF_NONE_MATCH)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
== Some(etag)
|
||||
{
|
||||
let mut response = StatusCode::NOT_MODIFIED.into_response();
|
||||
add_cache_headers(&mut response, etag, cache_control)?;
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let response = ServeFile::new(path)
|
||||
.oneshot(request)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let (parts, body) = response.into_parts();
|
||||
let mut response = Response::from_parts(parts, Body::new(body));
|
||||
add_cache_headers(&mut response, etag, cache_control)?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn add_cache_headers(
|
||||
response: &mut Response,
|
||||
etag: &str,
|
||||
cache_control: &'static str,
|
||||
) -> Result<(), StatusCode> {
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static(cache_control),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::ETAG,
|
||||
HeaderValue::from_str(etag).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::X_CONTENT_TYPE_OPTIONS,
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn app_js() -> Response {
|
||||
embedded(APP_JS, "text/javascript; charset=utf-8", true)
|
||||
}
|
||||
|
||||
async fn app_css() -> Response {
|
||||
embedded(APP_CSS, "text/css; charset=utf-8", true)
|
||||
}
|
||||
|
||||
async fn frontend(request: Request) -> 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)
|
||||
}
|
||||
|
||||
fn embedded(content: &'static [u8], content_type: &'static str, immutable: bool) -> 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::X_CONTENT_TYPE_OPTIONS,
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn image_view_uses_versioned_id_urls() {
|
||||
let record = ImageRecord {
|
||||
id: "abc".into(),
|
||||
path: "photo.jpg".into(),
|
||||
relative_path: "album/photo.jpg".into(),
|
||||
name: "photo.jpg".into(),
|
||||
album: "album".into(),
|
||||
width: 10,
|
||||
height: 20,
|
||||
size: 30,
|
||||
modified_ms: 40,
|
||||
modified_ns: 40,
|
||||
};
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user