feat: Image Viewer enhancements

This commit is contained in:
shiyu
2025-09-23 14:49:09 +08:00
parent 6444ed264c
commit cf5f19043b
8 changed files with 1142 additions and 346 deletions

View File

@@ -1,394 +1,654 @@
import React, { useEffect, useRef, useState } from 'react';
import { vfsApi } from '../../api/client';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
FileOutlined,
DatabaseOutlined,
ExpandOutlined,
BgColorsOutlined,
ClockCircleOutlined,
FolderOutlined,
AimOutlined,
BulbOutlined,
ThunderboltOutlined,
AlertOutlined,
CameraOutlined,
ApiOutlined,
FieldTimeOutlined,
} from '@ant-design/icons';
import { API_BASE_URL, vfsApi, type VfsEntry } from '../../api/client';
import type { AppComponentProps } from '../types';
import { Spin, Typography, Button, Tooltip } from 'antd';
import { ZoomInOutlined, ZoomOutOutlined, ReloadOutlined, CompressOutlined, CloseOutlined, RotateRightOutlined } from '@ant-design/icons';
import { ImageCanvas } from './components/ImageCanvas';
import { ViewerControls } from './components/ViewerControls';
import { Filmstrip } from './components/Filmstrip';
import { InfoPanel } from './components/InfoPanel';
import type { HistogramData, RgbColor, InfoItem } from './components/types';
import { viewerStyles } from './styles';
interface ExplorerSnapshot {
path: string;
entries: VfsEntry[];
pagination?: { page: number; page_size: number; total: number };
sortBy?: string;
sortOrder?: string;
timestamp: number;
}
interface FileStat {
name?: string;
is_dir?: boolean;
size?: number;
mtime?: number;
mode?: number;
path?: string;
type?: string;
exif?: Record<string, unknown>;
}
declare global {
interface WindowEventMap {
'foxel:file-explorer-page': CustomEvent<ExplorerSnapshot>;
}
}
type ExplorerAwareWindow = Window & { __FOXEL_LAST_EXPLORER_PAGE__?: ExplorerSnapshot };
const DEFAULT_TONE: RgbColor = { r: 28, g: 32, b: 46 };
const isImageEntry = (ent: VfsEntry) => {
if (ent.is_dir) return false;
const maybe = ent as VfsEntry & { is_image?: boolean };
if (typeof maybe.is_image === 'boolean' && maybe.is_image) return true;
const ext = ent.name.split('.').pop()?.toLowerCase();
if (!ext) return false;
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'ico', 'tif', 'tiff', 'svg', 'heic', 'heif', 'arw', 'cr2', 'cr3', 'nef', 'rw2', 'orf', 'pef', 'dng'].includes(ext);
};
const buildThumbUrl = (fullPath: string, w = 180, h = 120) => {
const base = API_BASE_URL.replace(/\/+$/, '');
const clean = fullPath.replace(/^\/+/, '');
return `${base}/fs/thumb/${encodeURI(clean)}?w=${w}&h=${h}&fit=cover`;
};
const getDirectory = (fullPath: string) => {
const path = fullPath.startsWith('/') ? fullPath : `/${fullPath}`;
const idx = path.lastIndexOf('/');
if (idx <= 0) return '/';
return path.slice(0, idx) || '/';
};
const joinPath = (dir: string, name: string) => {
if (dir === '/' || dir === '') return `/${name}`;
return `${dir.replace(/\/$/, '')}/${name}`;
};
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
const parseNumberish = (raw: unknown): number | null => {
if (typeof raw === 'number') return raw;
if (typeof raw !== 'string') return null;
if (raw.includes('/')) {
const [a, b] = raw.split('/').map(v => Number(v));
if (!Number.isNaN(a) && !Number.isNaN(b) && b !== 0) return a / b;
}
const val = Number(raw);
return Number.isNaN(val) ? null : val;
};
const humanFileSize = (size: number | undefined) => {
if (typeof size !== 'number') return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = size;
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
};
const readExplorerSnapshot = (dir: string): ExplorerSnapshot | null => {
if (typeof window === 'undefined') return null;
const snap = (window as ExplorerAwareWindow).__FOXEL_LAST_EXPLORER_PAGE__;
if (!snap) return null;
const snapshotPath = snap.path === '' ? '/' : snap.path;
const normalizedSnap = snapshotPath.endsWith('/') && snapshotPath !== '/' ? snapshotPath.slice(0, -1) : snapshotPath;
const normalizedTarget = dir.endsWith('/') && dir !== '/' ? dir.slice(0, -1) : dir;
if (normalizedSnap !== normalizedTarget) return null;
return snap;
};
const formatDateTime = (ts?: number) => {
if (!ts) return '-';
try {
return new Date(ts * 1000).toLocaleString();
} catch {
return '-';
}
};
const clampChannel = (value: number) => Math.max(0, Math.min(255, value));
const mixColor = (base: RgbColor, target: RgbColor, ratio: number): RgbColor => ({
r: clampChannel(base.r * (1 - ratio) + target.r * ratio),
g: clampChannel(base.g * (1 - ratio) + target.g * ratio),
b: clampChannel(base.b * (1 - ratio) + target.b * ratio),
});
const rgbToRgba = (color: RgbColor, alpha: number) => `rgba(${Math.round(color.r)}, ${Math.round(color.g)}, ${Math.round(color.b)}, ${alpha})`;
const computeImageStats = (img: HTMLImageElement): { histogram: HistogramData | null; dominantColor: RgbColor | null } => {
try {
const maxSide = 720;
const naturalWidth = img.naturalWidth || 1;
const naturalHeight = img.naturalHeight || 1;
const ratio = Math.min(1, maxSide / Math.max(naturalWidth, naturalHeight));
const width = Math.max(1, Math.floor(naturalWidth * ratio));
const height = Math.max(1, Math.floor(naturalHeight * ratio));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return { histogram: null, dominantColor: null };
ctx.drawImage(img, 0, 0, width, height);
const { data } = ctx.getImageData(0, 0, width, height);
const r = new Array(256).fill(0);
const g = new Array(256).fill(0);
const b = new Array(256).fill(0);
let rTotal = 0;
let gTotal = 0;
let bTotal = 0;
let count = 0;
for (let i = 0; i < data.length; i += 4) {
r[data[i]] += 1;
g[data[i + 1]] += 1;
b[data[i + 2]] += 1;
rTotal += data[i];
gTotal += data[i + 1];
bTotal += data[i + 2];
count += 1;
}
const histogram: HistogramData = { r, g, b };
if (count === 0) return { histogram, dominantColor: null };
const dominantColor: RgbColor = {
r: rTotal / count,
g: gTotal / count,
b: bTotal / count,
};
return { histogram, dominantColor };
} catch {
return { histogram: null, dominantColor: null };
}
};
export const ImageViewerApp: React.FC<AppComponentProps> = ({ filePath, entry, onRequestClose }) => {
const [url, setUrl] = useState<string>();
const normalizedInitialPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
const [activeEntry, setActiveEntry] = useState<VfsEntry>(entry);
const [activePath, setActivePath] = useState<string>(normalizedInitialPath);
const [imageUrl, setImageUrl] = useState<string>();
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string>();
const [error, setError] = useState<string>();
const [stat, setStat] = useState<FileStat | null>(null);
const [histogram, setHistogram] = useState<HistogramData | null>(null);
const [dominantColor, setDominantColor] = useState<RgbColor | null>(null);
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [rotate, setRotate] = useState(0);
const imgRef = useRef<HTMLImageElement | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [filmstrip, setFilmstrip] = useState<VfsEntry[]>([]);
const [pageInfo, setPageInfo] = useState<{ page: number; total: number; pageSize: number } | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const lastPointer = useRef<{ x: number; y: number } | null>(null);
const lastDistance = useRef<number | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const dragPointRef = useRef<{ x: number; y: number } | null>(null);
const pinchDistanceRef = useRef<number | null>(null);
const transitionRef = useRef(false);
const filmstripRefs = useRef<Record<string, HTMLDivElement | null>>({});
const directory = useMemo(() => getDirectory(activePath), [activePath]);
const baseTone = useMemo<RgbColor>(() => dominantColor ?? DEFAULT_TONE, [dominantColor]);
const containerStyle = useMemo(() => {
const light = mixColor(baseTone, { r: 255, g: 255, b: 255 }, 0.18);
const shadow = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.62);
return {
...viewerStyles.container,
background: `linear-gradient(135deg, ${rgbToRgba(light, 0.78)} 0%, ${rgbToRgba(baseTone, 0.86)} 48%, ${rgbToRgba(shadow, 0.96)} 100%)`,
};
}, [baseTone]);
const mainBackdropStyle = useMemo(() => {
const glow = mixColor(baseTone, { r: 255, g: 255, b: 255 }, 0.32);
const shade = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.7);
return {
...viewerStyles.mainBackdrop,
background: `radial-gradient(circle at 18% 22%, ${rgbToRgba(glow, 0.38)}, ${rgbToRgba(shade, 0.94)} 68%)`,
};
}, [baseTone]);
const viewerStyle = useMemo(() => {
const surface = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.45);
const edge = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.65);
return {
...viewerStyles.viewer,
background: `linear-gradient(145deg, ${rgbToRgba(surface, 0.7)} 0%, ${rgbToRgba(edge, 0.92)} 100%)`,
backdropFilter: 'blur(28px)',
};
}, [baseTone]);
const controlsStyle = useMemo(() => {
const tone = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.52);
return {
...viewerStyles.controls,
background: rgbToRgba(tone, 0.74),
backdropFilter: 'blur(18px)',
};
}, [baseTone]);
const filmstripShellStyle = useMemo(() => {
const tone = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.56);
return {
...viewerStyles.filmstripShell,
background: rgbToRgba(tone, 0.7),
backdropFilter: 'blur(22px)',
};
}, [baseTone]);
const getThumbUrl = useCallback((item: VfsEntry) => {
const full = joinPath(directory, item.name);
return buildThumbUrl(full, 160, 120);
}, [directory]);
const sidePanelStyle = useMemo(() => {
const panel = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.6);
const border = rgbToRgba(mixColor(baseTone, { r: 255, g: 255, b: 255 }, 0.1), 0.28);
return {
...viewerStyles.sidePanel,
background: rgbToRgba(panel, 0.8),
backdropFilter: 'blur(28px)',
borderLeft: `1px solid ${border}`,
};
}, [baseTone]);
const histogramCardStyle = useMemo(() => {
const tone = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.55);
const stroke = rgbToRgba(mixColor(baseTone, { r: 255, g: 255, b: 255 }, 0.12), 0.2);
return {
...viewerStyles.histogramCard,
background: rgbToRgba(tone, 0.58),
border: `1px solid ${stroke}`,
};
}, [baseTone]);
useEffect(() => {
const normalized = filePath.startsWith('/') ? filePath : `/${filePath}`;
setActiveEntry(entry);
setActivePath(normalized);
}, [entry, filePath]);
useEffect(() => {
let cancelled = false;
setLoading(true); setErr(undefined);
vfsApi.getTempLinkToken(filePath.replace(/^\/+/, ''))
.then(res => {
setLoading(true);
setError(undefined);
setHistogram(null);
setDominantColor(null);
const cleaned = activePath.replace(/^\/+/, '');
Promise.all([
vfsApi.getTempLinkToken(cleaned),
vfsApi.stat(activePath) as Promise<FileStat>,
])
.then(([token, metadata]) => {
if (cancelled) return;
const publicUrl = vfsApi.getTempPublicUrl(res.token);
setUrl(publicUrl);
setImageUrl(vfsApi.getTempPublicUrl(token.token));
setStat(metadata);
setScale(1);
setRotate(0);
setOffset({ x: 0, y: 0 });
})
.catch(e => !cancelled && setErr(e.message || '加载失败'))
.finally(() => !cancelled && setLoading(false));
return () => { cancelled = true; };
}, [filePath]);
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : '加载失败');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [activePath]);
const refreshFilmstrip = useCallback((dir: string) => {
const snap = readExplorerSnapshot(dir);
if (snap) {
const images = snap.entries.filter(isImageEntry);
const ensured = images.some(item => item.name === activeEntry.name) ? images : [...images, activeEntry];
setFilmstrip(ensured);
if (snap.pagination) {
setPageInfo({
page: snap.pagination.page,
pageSize: snap.pagination.page_size,
total: snap.pagination.total,
});
} else {
setPageInfo(null);
}
return;
}
setFilmstrip([activeEntry]);
setPageInfo(null);
}, [activeEntry]);
useEffect(() => {
refreshFilmstrip(directory);
}, [directory, refreshFilmstrip]);
useEffect(() => {
const handler = () => refreshFilmstrip(directory);
window.addEventListener('foxel:file-explorer-page', handler);
return () => window.removeEventListener('foxel:file-explorer-page', handler);
}, [directory, refreshFilmstrip]);
useEffect(() => {
const el = filmstripRefs.current[activeEntry.name];
if (el) {
el.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}
}, [activeEntry, filmstrip]);
useEffect(() => {
const keyHandler = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight') {
e.preventDefault();
switchRelative(1);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
switchRelative(-1);
} else if ((e.key === '+' || e.key === '=') && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
zoom(1.15);
} else if ((e.key === '-' || e.key === '_') && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
zoom(0.85);
}
};
window.addEventListener('keydown', keyHandler);
return () => window.removeEventListener('keydown', keyHandler);
});
const zoom = useCallback((factor: number) => {
setScale(prev => {
const next = clamp(prev * factor, 0.08, 10);
transitionRef.current = true;
window.setTimeout(() => { transitionRef.current = false; }, 120);
return next;
});
}, []);
const rotateImage = () => {
setRotate(prev => {
transitionRef.current = true;
window.setTimeout(() => { transitionRef.current = false; }, 180);
return (prev + 90) % 360;
});
};
const resetView = () => {
transitionRef.current = true;
window.setTimeout(() => { transitionRef.current = false; }, 160);
setScale(1);
setOffset({ x: 0, y: 0 });
setRotate(0);
}, [url]);
};
const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
const applyOffset = (next: { x: number; y: number }) => {
setOffset(next);
const fitToScreen = () => {
resetView();
};
const onWheel = (e: React.WheelEvent) => {
e.preventDefault();
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const cx = e.clientX - rect.left - rect.width / 2;
const cy = e.clientY - rect.top - rect.height / 2;
setScale(prev => {
const factor = e.deltaY < 0 ? 1.12 : 0.88;
const next = clamp(prev * factor, 0.08, 10);
const ratio = next / prev;
setOffset(off => ({ x: off.x - cx * (ratio - 1), y: off.y - cy * (ratio - 1) }));
transitionRef.current = true;
window.setTimeout(() => { transitionRef.current = false; }, 120);
return next;
});
};
const onMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return;
e.preventDefault();
setIsDragging(true);
lastPointer.current = { x: e.clientX, y: e.clientY };
transitionRef.current = false;
dragPointRef.current = { x: e.clientX, y: e.clientY };
};
const onMouseMove = (e: React.MouseEvent) => {
if (!isDragging || !lastPointer.current) return;
if (!isDragging || !dragPointRef.current) return;
e.preventDefault();
const dx = e.clientX - lastPointer.current.x;
const dy = e.clientY - lastPointer.current.y;
lastPointer.current = { x: e.clientX, y: e.clientY };
applyOffset({ x: offset.x + dx, y: offset.y + dy });
const dx = e.clientX - dragPointRef.current.x;
const dy = e.clientY - dragPointRef.current.y;
dragPointRef.current = { x: e.clientX, y: e.clientY };
setOffset(off => ({ x: off.x + dx, y: off.y + dy }));
};
const onMouseUp = () => {
const stopDragging = () => {
setIsDragging(false);
lastPointer.current = null;
dragPointRef.current = null;
};
const dist = (t1: React.Touch, t2: React.Touch) => Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
const onTouchStart = (e: React.TouchEvent) => {
if (e.touches.length === 1) {
const t = e.touches[0];
dragPointRef.current = { x: t.clientX, y: t.clientY };
} else if (e.touches.length === 2) {
pinchDistanceRef.current = dist(e.touches[0], e.touches[1]);
}
};
const onTouchMove = (e: React.TouchEvent) => {
if (e.touches.length === 1 && dragPointRef.current) {
const t = e.touches[0];
const dx = t.clientX - dragPointRef.current.x;
const dy = t.clientY - dragPointRef.current.y;
dragPointRef.current = { x: t.clientX, y: t.clientY };
setOffset(off => ({ x: off.x + dx, y: off.y + dy }));
} else if (e.touches.length === 2 && pinchDistanceRef.current) {
const dNow = dist(e.touches[0], e.touches[1]);
const ratio = dNow / pinchDistanceRef.current;
pinchDistanceRef.current = dNow;
setScale(prev => clamp(prev * ratio, 0.08, 10));
}
};
const onTouchEnd = () => {
pinchDistanceRef.current = null;
dragPointRef.current = null;
};
const onDoubleClick = (e: React.MouseEvent) => {
e.preventDefault();
const cont = containerRef.current;
const img = imgRef.current;
if (!cont || !img) return;
const rect = cont.getBoundingClientRect();
const next = scale > 1.4 ? 1 : 2.2;
const container = containerRef.current;
if (!container) {
setScale(next);
return;
}
const rect = container.getBoundingClientRect();
const cx = e.clientX - rect.left - rect.width / 2;
const cy = e.clientY - rect.top - rect.height / 2;
const nextScale = scale > 1.5 ? 1 : 2.5;
const ratio = nextScale / scale;
const nextOffset = { x: offset.x - cx * (ratio - 1), y: offset.y - cy * (ratio - 1) };
setScale(nextScale);
transitionRef.current = true;
setTimeout(() => transitionRef.current = false, 200);
applyOffset(nextOffset);
const ratio = next / scale;
setScale(next);
setOffset(off => ({ x: off.x - cx * (ratio - 1), y: off.y - cy * (ratio - 1) }));
};
const onWheel = (e: React.WheelEvent) => {
e.preventDefault();
const delta = -e.deltaY;
const zoomFactor = delta > 0 ? 1.12 : 0.88;
const cont = containerRef.current;
if (!cont) return;
const rect = cont.getBoundingClientRect();
const cx = e.clientX - rect.left - rect.width / 2;
const cy = e.clientY - rect.top - rect.height / 2;
const nextScale = clamp(scale * zoomFactor, 0.5, 5);
const ratio = nextScale / scale;
const nextOffset = { x: offset.x - cx * (ratio - 1), y: offset.y - cy * (ratio - 1) };
setScale(nextScale);
transitionRef.current = true;
setTimeout(() => transitionRef.current = false, 120);
applyOffset(nextOffset);
const handleImageLoaded = () => {
const img = imageRef.current;
if (!img) return;
const stats = computeImageStats(img);
setHistogram(stats.histogram);
setDominantColor(stats.dominantColor);
};
const getTouchDistance = (t1: { clientX: number; clientY: number }, t2: { clientX: number; clientY: number }) =>
Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
const onTouchStart = (e: React.TouchEvent) => {
if (e.touches.length === 1) {
const t = e.touches[0];
lastPointer.current = { x: t.clientX, y: t.clientY };
} else if (e.touches.length === 2) {
lastDistance.current = getTouchDistance(e.touches[0], e.touches[1]);
}
transitionRef.current = false;
};
const onTouchMove = (e: React.TouchEvent) => {
if (e.touches.length === 1 && lastPointer.current) {
const t = e.touches[0];
const dx = t.clientX - lastPointer.current.x;
const dy = t.clientY - lastPointer.current.y;
lastPointer.current = { x: t.clientX, y: t.clientY };
applyOffset({ x: offset.x + dx, y: offset.y + dy });
} else if (e.touches.length === 2 && lastDistance.current) {
const d = getTouchDistance(e.touches[0], e.touches[1]);
const ratio = d / lastDistance.current;
const nextScale = clamp(scale * ratio, 0.5, 5);
setScale(nextScale);
lastDistance.current = d;
}
};
const onTouchEnd = (e: React.TouchEvent) => {
if (e.touches.length === 0) {
lastPointer.current = null;
lastDistance.current = null;
}
};
const doZoom = (factor: number) => {
const nextScale = clamp(scale * factor, 0.5, 5);
setScale(nextScale);
transitionRef.current = true;
setTimeout(() => transitionRef.current = false, 120);
applyOffset(offset);
};
const resetView = () => {
setScale(1);
setOffset({ x: 0, y: 0 });
setRotate(0);
transitionRef.current = true;
setTimeout(() => transitionRef.current = false, 150);
};
const fitToContainer = () => {
setScale(1);
setOffset({ x: 0, y: 0 });
setRotate(0);
transitionRef.current = true;
setTimeout(() => transitionRef.current = false, 150);
};
const doRotate = () => {
setRotate(r => (r + 90) % 360);
transitionRef.current = true;
setTimeout(() => transitionRef.current = false, 180);
const switchEntry = (target: VfsEntry) => {
const nextPath = joinPath(directory, target.name);
setActiveEntry(target);
setActivePath(nextPath);
};
if (loading) {
return (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(20,20,20,0.8)',
backdropFilter: 'blur(24px)'
}}>
<Spin />
</div>
);
}
if (err) {
return (
<div style={{
color: 'var(--ant-color-error, #f5222d)',
padding: 16,
background: 'rgba(20,20,20,0.8)',
backdropFilter: 'blur(24px)'
}}>
: {err}
</div>
);
}
if (!url) {
return (
<div style={{
padding: 16,
background: 'rgba(20,20,20,0.8)',
backdropFilter: 'blur(24px)'
}}>
</div>
);
}
const switchRelative = (step: number) => {
if (filmstrip.length <= 1) return;
const currentIndex = filmstrip.findIndex(item => item.name === activeEntry.name);
if (currentIndex === -1) return;
const target = filmstrip[(currentIndex + step + filmstrip.length) % filmstrip.length];
if (target) switchEntry(target);
};
const scaleLabel = `${(scale * 100).toFixed(scale >= 1 ? 0 : 1)}%`;
const imageStyle: React.CSSProperties = {
maxWidth: '100%',
maxHeight: '100%',
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale}) rotate(${rotate}deg)`,
transition: transitionRef.current ? 'transform 0.18s cubic-bezier(.4,.8,.4,1)' : undefined,
cursor: isDragging ? 'grabbing' : scale > 1 ? 'grab' : 'zoom-in',
willChange: 'transform',
};
const controlsNode = (
<ViewerControls
style={controlsStyle}
onPrev={() => switchRelative(-1)}
onNext={() => switchRelative(1)}
onZoomIn={() => zoom(1.18)}
onZoomOut={() => zoom(0.82)}
onRotate={rotateImage}
onReset={resetView}
onFit={fitToScreen}
disableSwitch={filmstrip.length <= 1}
/>
);
const exif = (stat?.exif ?? {}) as Record<string, unknown>;
const infoIconStyle: React.CSSProperties = { fontSize: 15, color: 'rgba(255,255,255,0.62)' };
const exifValue = (key: string): string | number | null => {
const value = exif[key];
if (typeof value === 'string' || typeof value === 'number') return value;
return null;
};
const focalLength = (() => {
const v = parseNumberish(exifValue('37386') ?? exifValue('37377'));
return v ? `${v.toFixed(1)} mm` : null;
})();
const aperture = (() => {
const v = parseNumberish(exifValue('33437') ?? exifValue('37378'));
return v ? `f/${v.toFixed(1)}` : null;
})();
const exposure = (() => {
const v = parseNumberish(exifValue('33434'));
if (!v) return null;
if (v >= 1) return `${v.toFixed(1)} s`;
const denom = Math.max(1, Math.round(1 / v));
return `1/${denom}`;
})();
const isoValue = exifValue('34855') ?? exifValue('34864');
const width = parseNumberish(exifValue('40962'));
const height = parseNumberish(exifValue('40963'));
const colorSpace = exifValue('40961');
const cameraMake = exifValue('271');
const cameraModel = exifValue('272');
const lensModel = exifValue('42036');
const captureTime = exifValue('36867') ?? exifValue('36868') ?? exifValue('306');
const basicList: InfoItem[] = [
{ label: '文件名', value: activeEntry.name, icon: <FileOutlined style={infoIconStyle} /> },
{ label: '文件大小', value: humanFileSize(stat?.size), icon: <DatabaseOutlined style={infoIconStyle} /> },
{ label: '分辨率', value: width && height ? `${width} × ${height}` : null, icon: <ExpandOutlined style={infoIconStyle} /> },
{ label: '颜色空间', value: colorSpace ?? null, icon: <BgColorsOutlined style={infoIconStyle} /> },
{ label: '修改时间', value: stat?.mtime ? formatDateTime(stat.mtime) : null, icon: <ClockCircleOutlined style={infoIconStyle} /> },
{ label: '路径', value: typeof stat?.path === 'string' ? stat.path : activePath, icon: <FolderOutlined style={infoIconStyle} /> },
];
const shootingList: InfoItem[] = [
{ label: '焦距', value: focalLength, icon: <AimOutlined style={infoIconStyle} /> },
{ label: '光圈', value: aperture, icon: <BulbOutlined style={infoIconStyle} /> },
{ label: '快门', value: exposure, icon: <ThunderboltOutlined style={infoIconStyle} /> },
{ label: 'ISO', value: isoValue != null ? isoValue.toString() : null, icon: <AlertOutlined style={infoIconStyle} /> },
];
const deviceList: InfoItem[] = [
{
label: '相机',
value: cameraModel ? `${cameraMake ? `${cameraMake} ` : ''}${cameraModel}` : (cameraMake ?? null),
icon: <CameraOutlined style={infoIconStyle} />,
},
{ label: '镜头', value: lensModel ?? null, icon: <ApiOutlined style={infoIconStyle} /> },
];
const miscList: InfoItem[] = [
{ label: '拍摄时间', value: captureTime, icon: <FieldTimeOutlined style={infoIconStyle} /> },
];
return (
<div
ref={containerRef}
onWheel={onWheel}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
onMouseDown={onMouseDown}
onDoubleClick={onDoubleClick}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
style={{
width: '100%',
height: '100%',
overflow: 'hidden',
position: 'relative',
background: 'rgba(20,20,20,0.8)',
backdropFilter: 'blur(24px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
userSelect: 'none',
touchAction: 'none'
}}
>
{/* 顶部栏:文件名和关闭按钮 */}
<div style={{
position: 'absolute',
top: 32,
left: 32,
right: 32,
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
pointerEvents: 'none'
}}>
<Typography.Paragraph
style={{
color: '#fff',
margin: 0,
fontSize: 15,
background: 'rgba(0,0,0,0.32)',
padding: '7px 18px',
borderRadius: 8,
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
backdropFilter: 'blur(2px)',
maxWidth: '60vw',
textAlign: 'left',
pointerEvents: 'auto'
}}
ellipsis
>
{entry.name} <span style={{ opacity: 0.7, fontSize: 13 }}>({(entry.size / 1024).toFixed(1)} KB)</span>
</Typography.Paragraph>
<Tooltip title="关闭">
<Button
shape="circle"
size="large"
type="text"
onClick={() => onRequestClose && onRequestClose()}
icon={<CloseOutlined />}
style={{
color: '#fff',
background: 'rgba(30,30,30,0.55)',
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
border: 'none',
backdropFilter: 'blur(4px)',
pointerEvents: 'auto'
}}
<div style={containerStyle}>
<section style={viewerStyles.main}>
<div style={mainBackdropStyle} />
<div style={viewerStyles.mainContent}>
<ImageCanvas
containerRef={containerRef}
imageRef={imageRef}
viewerStyle={viewerStyle}
controls={controlsNode}
scaleLabel={scaleLabel}
imageStyle={imageStyle}
loading={loading}
error={error}
imageUrl={imageUrl}
activeEntry={activeEntry}
onRequestClose={onRequestClose}
onImageLoad={handleImageLoaded}
onWheel={onWheel}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseLeave={stopDragging}
onMouseUp={stopDragging}
onDoubleClick={onDoubleClick}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
/>
</Tooltip>
</div>
{/* 图片居中显示 */}
<div style={{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column'
}}>
<img
ref={imgRef}
src={url}
alt={entry.name}
draggable={false}
onDragStart={e => e.preventDefault()}
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale}) rotate(${rotate}deg)`,
transition: transitionRef.current ? 'transform 0.18s cubic-bezier(.4,.8,.4,1)' : undefined,
maxWidth: '80vw',
maxHeight: '80vh',
objectFit: 'contain',
borderRadius: 18,
boxShadow: '0 8px 40px 0 rgba(0,0,0,0.45)',
cursor: isDragging ? 'grabbing' : (scale > 1 ? 'grab' : 'zoom-in'),
willChange: 'transform'
}}
/>
{/* 操作按钮组 */}
<div style={{
position: 'absolute',
bottom: 32,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: 18,
zIndex: 80
}}>
<Tooltip title="缩小">
<Button
shape="circle"
size="large"
icon={<ZoomOutOutlined style={{ fontSize: 22 }} />}
onClick={() => doZoom(0.8)}
style={{
color: '#fff',
background: 'rgba(30,30,30,0.55)',
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
border: 'none',
backdropFilter: 'blur(4px)'
}}
/>
</Tooltip>
<Tooltip title="放大">
<Button
shape="circle"
size="large"
icon={<ZoomInOutlined style={{ fontSize: 22 }} />}
onClick={() => doZoom(1.25)}
style={{
color: '#fff',
background: 'rgba(30,30,30,0.55)',
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
border: 'none',
backdropFilter: 'blur(4px)'
}}
/>
</Tooltip>
<Tooltip title="旋转">
<Button
shape="circle"
size="large"
icon={<RotateRightOutlined style={{ fontSize: 20 }} />}
onClick={doRotate}
style={{
color: '#fff',
background: 'rgba(30,30,30,0.55)',
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
border: 'none',
backdropFilter: 'blur(4px)'
}}
/>
</Tooltip>
<Tooltip title="重置">
<Button
shape="circle"
size="large"
icon={<ReloadOutlined style={{ fontSize: 20 }} />}
onClick={resetView}
style={{
color: '#fff',
background: 'rgba(30,30,30,0.55)',
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
border: 'none',
backdropFilter: 'blur(4px)'
}}
/>
</Tooltip>
<Tooltip title="适应窗口">
<Button
shape="circle"
size="large"
icon={<CompressOutlined style={{ fontSize: 20 }} />}
onClick={fitToContainer}
style={{
color: '#fff',
background: 'rgba(30,30,30,0.55)',
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
border: 'none',
backdropFilter: 'blur(4px)'
}}
/>
</Tooltip>
<Filmstrip
shellStyle={filmstripShellStyle}
listStyle={viewerStyles.filmstrip}
entries={filmstrip}
activeEntry={activeEntry}
onSelect={switchEntry}
filmstripRefs={filmstripRefs}
pageInfo={pageInfo}
getThumbUrl={getThumbUrl}
/>
</div>
</div>
</section>
<InfoPanel
style={sidePanelStyle}
histogramCardStyle={histogramCardStyle}
title={activeEntry.name}
captureTime={captureTime ?? null}
basicList={basicList}
shootingList={shootingList}
deviceList={deviceList}
miscList={miscList}
histogram={histogram}
/>
</div>
);
};

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { Typography } from 'antd';
import type { VfsEntry } from '../../../api/client';
interface PageInfo {
page: number;
total: number;
pageSize: number;
}
interface FilmstripProps {
shellStyle: React.CSSProperties;
listStyle: React.CSSProperties;
entries: VfsEntry[];
activeEntry: VfsEntry;
onSelect: (entry: VfsEntry) => void;
filmstripRefs: React.MutableRefObject<Record<string, HTMLDivElement | null>>;
pageInfo: PageInfo | null;
getThumbUrl: (entry: VfsEntry) => string;
}
export const Filmstrip: React.FC<FilmstripProps> = ({
shellStyle,
listStyle,
entries,
activeEntry,
onSelect,
filmstripRefs,
pageInfo,
getThumbUrl,
}) => (
<div style={shellStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<Typography.Text style={{ color: 'rgba(255,255,255,0.72)', fontWeight: 500 }}>
· {entries.length}
</Typography.Text>
{pageInfo && (
<Typography.Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 12 }}>
{pageInfo.page} / {Math.max(1, Math.ceil(pageInfo.total / pageInfo.pageSize))}
</Typography.Text>
)}
</div>
<div style={listStyle}>
{entries.map(item => {
const active = item.name === activeEntry.name;
return (
<div
key={`${item.name}-${item.mtime ?? ''}`}
ref={el => { filmstripRefs.current[item.name] = el; }}
onClick={() => onSelect(item)}
style={{
width: 84,
height: 64,
overflow: 'hidden',
border: active ? '2px solid #4e9bff' : '2px solid transparent',
boxShadow: active ? '0 0 0 4px rgba(78,155,255,0.28)' : '0 10px 28px rgba(0,0,0,0.45)',
cursor: 'pointer',
position: 'relative',
flex: '0 0 auto',
}}
>
<img
src={getThumbUrl(item)}
alt={item.name}
style={{ width: '100%', height: '100%', objectFit: 'cover', filter: active ? 'saturate(1)' : 'saturate(0.65)' }}
/>
{active && (
<div
style={{
position: 'absolute',
bottom: 4,
left: 6,
right: 6,
padding: '2px 4px',
background: 'rgba(0,0,0,0.55)',
color: '#fff',
fontSize: 10,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{item.name}
</div>
)}
</div>
);
})}
{entries.length === 0 && (
<div style={{ color: 'rgba(255,255,255,0.45)' }}></div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { Spin, Typography, Tooltip, Button } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import type { VfsEntry } from '../../../api/client';
import { viewerStyles } from '../styles';
interface ImageCanvasProps {
containerRef: React.RefObject<HTMLDivElement | null>;
imageRef: React.RefObject<HTMLImageElement | null>;
viewerStyle: React.CSSProperties;
controls: React.ReactNode;
scaleLabel: string;
imageStyle: React.CSSProperties;
loading: boolean;
error?: string;
imageUrl?: string;
activeEntry: VfsEntry;
onRequestClose: () => void;
onImageLoad: () => void;
onWheel: React.WheelEventHandler<HTMLDivElement>;
onMouseDown: React.MouseEventHandler<HTMLDivElement>;
onMouseMove: React.MouseEventHandler<HTMLDivElement>;
onMouseLeave: React.MouseEventHandler<HTMLDivElement>;
onMouseUp: React.MouseEventHandler<HTMLDivElement>;
onDoubleClick: React.MouseEventHandler<HTMLDivElement>;
onTouchStart: React.TouchEventHandler<HTMLDivElement>;
onTouchMove: React.TouchEventHandler<HTMLDivElement>;
onTouchEnd: React.TouchEventHandler<HTMLDivElement>;
}
export const ImageCanvas: React.FC<ImageCanvasProps> = ({
containerRef,
imageRef,
viewerStyle,
controls,
scaleLabel,
imageStyle,
loading,
error,
imageUrl,
activeEntry,
onRequestClose,
onImageLoad,
onWheel,
onMouseDown,
onMouseMove,
onMouseLeave,
onMouseUp,
onDoubleClick,
onTouchStart,
onTouchMove,
onTouchEnd,
}) => (
<div
ref={containerRef}
style={viewerStyle}
onWheel={onWheel}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
onMouseUp={onMouseUp}
onDoubleClick={onDoubleClick}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
>
<div style={viewerStyles.viewerCloseWrap}>
<Tooltip title="关闭">
<Button
type="text"
icon={<CloseOutlined />}
onClick={onRequestClose}
style={viewerStyles.viewerClose}
/>
</Tooltip>
</div>
{loading ? (
<Spin tip="加载中" />
) : error ? (
<Typography.Text type="danger">{error}</Typography.Text>
) : imageUrl ? (
<img
ref={imageRef}
src={imageUrl}
alt={activeEntry.name}
onLoad={onImageLoad}
draggable={false}
crossOrigin="anonymous"
style={imageStyle}
/>
) : (
<Typography.Text></Typography.Text>
)}
<div style={viewerStyles.scaleBadge}>{scaleLabel}</div>
{controls}
</div>
);

View File

@@ -0,0 +1,116 @@
import React from 'react';
import { Typography, Empty } from 'antd';
import type { HistogramData, InfoItem } from './types';
interface InfoPanelProps {
style: React.CSSProperties;
histogramCardStyle: React.CSSProperties;
title: string;
captureTime: string | number | null;
basicList: InfoItem[];
shootingList: InfoItem[];
deviceList: InfoItem[];
miscList: InfoItem[];
histogram: HistogramData | null;
}
const SectionTitle: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<Typography.Title level={5} style={{ color: '#fff', fontSize: 15, marginTop: 24, marginBottom: 12 }}>
{children}
</Typography.Title>
);
const HistogramPlot: React.FC<{ data: HistogramData | null }> = ({ data }) => {
if (!data) {
return <Empty description="无法解析直方图" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
}
const width = 260;
const height = 140;
const max = Math.max(...data.r, ...data.g, ...data.b, 1);
const toPath = (arr: number[]) => arr
.map((value, index) => {
const x = (index / 255) * width;
const y = height - (value / max) * height;
return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(' ');
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} style={{ width: '100%' }}>
<rect x={0} y={0} width={width} height={height} fill="rgba(255,255,255,0.04)" />
<path d={toPath(data.r)} stroke="rgba(255,99,132,0.88)" fill="none" strokeWidth={1.3} />
<path d={toPath(data.g)} stroke="rgba(75,192,192,0.88)" fill="none" strokeWidth={1.3} />
<path d={toPath(data.b)} stroke="rgba(54,162,235,0.88)" fill="none" strokeWidth={1.3} />
</svg>
);
};
const InfoRows: React.FC<{ items: InfoItem[] }> = ({ items }) => (
<div style={{ display: 'grid', gridTemplateColumns: '100px 1fr', rowGap: 10, columnGap: 12 }}>
{items
.filter(item => item.value !== null && item.value !== undefined && item.value !== '')
.map(item => (
<React.Fragment key={item.label}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'rgba(255,255,255,0.55)' }}>
{item.icon && <span style={{ display: 'inline-flex', alignItems: 'center' }}>{item.icon}</span>}
<span>{item.label}</span>
</span>
<span style={{ color: '#fff', wordBreak: 'break-all' }}>{item.value}</span>
</React.Fragment>
))}
</div>
);
export const InfoPanel: React.FC<InfoPanelProps> = ({
style,
histogramCardStyle,
title,
captureTime,
basicList,
shootingList,
deviceList,
miscList,
histogram,
}) => (
<aside style={style}>
<Typography.Title level={3} style={{ color: '#fff', marginTop: 6, wordBreak: 'break-all' }}>
{title}
</Typography.Title>
{captureTime && (
<Typography.Text style={{ color: 'rgba(255,255,255,0.6)' }}> {captureTime}</Typography.Text>
)}
<SectionTitle></SectionTitle>
<InfoRows items={basicList} />
{shootingList.some(i => i.value) && (
<>
<SectionTitle></SectionTitle>
<InfoRows items={shootingList} />
</>
)}
{deviceList.some(i => i.value) && (
<>
<SectionTitle></SectionTitle>
<InfoRows items={deviceList} />
</>
)}
{miscList.some(i => i.value) && (
<>
<SectionTitle></SectionTitle>
<InfoRows items={miscList} />
</>
)}
<SectionTitle></SectionTitle>
<div style={histogramCardStyle}>
<HistogramPlot data={histogram} />
<div style={{ marginTop: 12, display: 'flex', gap: 12, fontSize: 12 }}>
<span style={{ color: 'rgba(255,99,132,0.88)' }}>R</span>
<span style={{ color: 'rgba(75,192,192,0.88)' }}>G</span>
<span style={{ color: 'rgba(54,162,235,0.88)' }}>B</span>
</div>
</div>
</aside>
);

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { Button, Tooltip } from 'antd';
import {
LeftOutlined,
RightOutlined,
ZoomInOutlined,
ZoomOutOutlined,
RotateRightOutlined,
ReloadOutlined,
CompressOutlined,
} from '@ant-design/icons';
interface ViewerControlsProps {
style: React.CSSProperties;
onPrev: () => void;
onNext: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onRotate: () => void;
onReset: () => void;
onFit: () => void;
disableSwitch: boolean;
}
export const ViewerControls: React.FC<ViewerControlsProps> = ({
style,
onPrev,
onNext,
onZoomIn,
onZoomOut,
onRotate,
onReset,
onFit,
disableSwitch,
}) => (
<div style={style}>
<Tooltip title="上一张">
<Button
shape="circle"
type="text"
icon={<LeftOutlined />}
onClick={onPrev}
disabled={disableSwitch}
style={{ color: '#fff' }}
/>
</Tooltip>
<Tooltip title="缩小">
<Button shape="circle" type="text" icon={<ZoomOutOutlined />} onClick={onZoomOut} style={{ color: '#fff' }} />
</Tooltip>
<Tooltip title="放大">
<Button shape="circle" type="text" icon={<ZoomInOutlined />} onClick={onZoomIn} style={{ color: '#fff' }} />
</Tooltip>
<Tooltip title="旋转 90°">
<Button shape="circle" type="text" icon={<RotateRightOutlined />} onClick={onRotate} style={{ color: '#fff' }} />
</Tooltip>
<Tooltip title="重置">
<Button shape="circle" type="text" icon={<ReloadOutlined />} onClick={onReset} style={{ color: '#fff' }} />
</Tooltip>
<Tooltip title="适应窗口">
<Button shape="circle" type="text" icon={<CompressOutlined />} onClick={onFit} style={{ color: '#fff' }} />
</Tooltip>
<Tooltip title="下一张">
<Button
shape="circle"
type="text"
icon={<RightOutlined />}
onClick={onNext}
disabled={disableSwitch}
style={{ color: '#fff' }}
/>
</Tooltip>
</div>
);

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from 'react';
export interface HistogramData {
r: number[];
g: number[];
b: number[];
}
export interface RgbColor {
r: number;
g: number;
b: number;
}
export interface InfoItem {
label: string;
value: string | number | null;
icon?: ReactNode;
}

View File

@@ -0,0 +1,106 @@
export const viewerStyles = {
container: {
width: '100%',
height: '100%',
boxSizing: 'border-box' as const,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) 320px',
columnGap: 0,
color: '#fff',
overflow: 'hidden',
},
main: {
position: 'relative' as const,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column' as const,
boxShadow: '0 28px 80px rgba(0,0,0,0.55)',
minHeight: 0,
},
mainBackdrop: {
position: 'absolute' as const,
inset: 0,
},
mainContent: {
position: 'relative' as const,
zIndex: 1,
display: 'flex',
flexDirection: 'column' as const,
flex: 1,
padding: 0,
minHeight: 0,
minWidth: 0,
},
viewer: {
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative' as const,
overflow: 'hidden',
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
touchAction: 'none' as const,
minHeight: 0,
},
controls: {
position: 'absolute' as const,
bottom: 16,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: 16,
padding: '8px 18px',
borderRadius: 24,
alignItems: 'center',
},
scaleBadge: {
position: 'absolute' as const,
bottom: 64,
left: 16,
color: 'rgba(255,255,255,0.7)',
fontSize: 12,
letterSpacing: 0.2,
},
filmstripShell: {
marginTop: 0,
padding: '3px 12px',
boxShadow: '0 16px 42px rgba(0,0,0,0.52)',
},
filmstrip: {
display: 'flex',
overflowX: 'auto' as const,
gap: 12,
paddingBottom: 4,
},
sidePanel: {
boxShadow: '0 28px 80px rgba(0,0,0,0.55)',
padding: '20px 24px',
display: 'flex',
flexDirection: 'column' as const,
overflowY: 'auto' as const,
minHeight: 0,
},
histogramCard: {
padding: '12px 12px 18px',
background: 'rgba(0,0,0,0.34)',
borderRadius: 0,
},
viewerCloseWrap: {
position: 'absolute' as const,
top: 16,
right: 16,
zIndex: 2,
},
viewerClose: {
color: '#fff',
background: 'rgba(0,0,0,0.4)',
border: '1px solid rgba(255,255,255,0.25)',
boxShadow: '0 8px 18px rgba(0,0,0,0.45)',
borderRadius: '100%',
width: 32,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
};

View File

@@ -2,6 +2,20 @@ import { useState, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { message } from 'antd';
import { vfsApi, type VfsEntry } from '../../../api/client';
type ExplorerSnapshot = {
path: string;
entries: VfsEntry[];
pagination?: {
total: number;
page: number;
page_size: number;
pages: number;
};
sortBy: string;
sortOrder: string;
timestamp: number;
};
import { processorsApi, type ProcessorTypeMeta } from '../../../api/processors';
export function useFileExplorer(navKey: string) {
@@ -34,7 +48,8 @@ export function useFileExplorer(navKey: string) {
processorsApi.list()
]);
setEntries(res.entries);
setPath(res.path || canonical);
const resolvedPath = res.path || canonical;
setPath(resolvedPath);
setPagination(prev => ({
...prev,
current: res.pagination!.page,
@@ -42,8 +57,22 @@ export function useFileExplorer(navKey: string) {
total: res.pagination!.total
}));
setProcessorTypes(processors);
} catch (e: any) {
message.error(e.message || 'Load failed');
if (typeof window !== 'undefined') {
const snapshot: ExplorerSnapshot = {
path: resolvedPath,
entries: res.entries,
pagination: res.pagination,
sortBy: sb,
sortOrder: so,
timestamp: Date.now(),
};
const explorerWindow = window as Window & { __FOXEL_LAST_EXPLORER_PAGE__?: ExplorerSnapshot };
explorerWindow.__FOXEL_LAST_EXPLORER_PAGE__ = snapshot;
window.dispatchEvent(new CustomEvent<ExplorerSnapshot>('foxel:file-explorer-page', { detail: snapshot }));
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Load failed';
message.error(msg);
} finally {
setLoading(false);
}