mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-07 00:27:44 +08:00
feat: Image Viewer enhancements
This commit is contained in:
@@ -1,394 +1,654 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { vfsApi } from '../../api/client';
|
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 type { AppComponentProps } from '../types';
|
||||||
import { Spin, Typography, Button, Tooltip } from 'antd';
|
import { ImageCanvas } from './components/ImageCanvas';
|
||||||
import { ZoomInOutlined, ZoomOutOutlined, ReloadOutlined, CompressOutlined, CloseOutlined, RotateRightOutlined } from '@ant-design/icons';
|
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 }) => {
|
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 [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 [scale, setScale] = useState(1);
|
||||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [rotate, setRotate] = useState(0);
|
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 containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const lastPointer = useRef<{ x: number; y: number } | null>(null);
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||||
const lastDistance = useRef<number | null>(null);
|
const dragPointRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const pinchDistanceRef = useRef<number | null>(null);
|
||||||
const transitionRef = useRef(false);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true); setErr(undefined);
|
setLoading(true);
|
||||||
vfsApi.getTempLinkToken(filePath.replace(/^\/+/, ''))
|
setError(undefined);
|
||||||
.then(res => {
|
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;
|
if (cancelled) return;
|
||||||
const publicUrl = vfsApi.getTempPublicUrl(res.token);
|
setImageUrl(vfsApi.getTempPublicUrl(token.token));
|
||||||
setUrl(publicUrl);
|
setStat(metadata);
|
||||||
|
setScale(1);
|
||||||
|
setRotate(0);
|
||||||
|
setOffset({ x: 0, y: 0 });
|
||||||
})
|
})
|
||||||
.catch(e => !cancelled && setErr(e.message || '加载失败'))
|
.catch((err: unknown) => {
|
||||||
.finally(() => !cancelled && setLoading(false));
|
if (!cancelled) {
|
||||||
return () => { cancelled = true; };
|
setError(err instanceof Error ? err.message : '加载失败');
|
||||||
}, [filePath]);
|
}
|
||||||
|
})
|
||||||
|
.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(() => {
|
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);
|
setScale(1);
|
||||||
setOffset({ x: 0, y: 0 });
|
setOffset({ x: 0, y: 0 });
|
||||||
setRotate(0);
|
setRotate(0);
|
||||||
}, [url]);
|
};
|
||||||
|
|
||||||
const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
|
const fitToScreen = () => {
|
||||||
const applyOffset = (next: { x: number; y: number }) => {
|
resetView();
|
||||||
setOffset(next);
|
};
|
||||||
|
|
||||||
|
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) => {
|
const onMouseDown = (e: React.MouseEvent) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
lastPointer.current = { x: e.clientX, y: e.clientY };
|
dragPointRef.current = { x: e.clientX, y: e.clientY };
|
||||||
transitionRef.current = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMouseMove = (e: React.MouseEvent) => {
|
const onMouseMove = (e: React.MouseEvent) => {
|
||||||
if (!isDragging || !lastPointer.current) return;
|
if (!isDragging || !dragPointRef.current) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const dx = e.clientX - lastPointer.current.x;
|
const dx = e.clientX - dragPointRef.current.x;
|
||||||
const dy = e.clientY - lastPointer.current.y;
|
const dy = e.clientY - dragPointRef.current.y;
|
||||||
lastPointer.current = { x: e.clientX, y: e.clientY };
|
dragPointRef.current = { x: e.clientX, y: e.clientY };
|
||||||
applyOffset({ x: offset.x + dx, y: offset.y + dy });
|
setOffset(off => ({ x: off.x + dx, y: off.y + dy }));
|
||||||
};
|
};
|
||||||
const onMouseUp = () => {
|
|
||||||
|
const stopDragging = () => {
|
||||||
setIsDragging(false);
|
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) => {
|
const onDoubleClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const cont = containerRef.current;
|
const next = scale > 1.4 ? 1 : 2.2;
|
||||||
const img = imgRef.current;
|
const container = containerRef.current;
|
||||||
if (!cont || !img) return;
|
if (!container) {
|
||||||
const rect = cont.getBoundingClientRect();
|
setScale(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
const cx = e.clientX - rect.left - rect.width / 2;
|
const cx = e.clientX - rect.left - rect.width / 2;
|
||||||
const cy = e.clientY - rect.top - rect.height / 2;
|
const cy = e.clientY - rect.top - rect.height / 2;
|
||||||
|
const ratio = next / scale;
|
||||||
const nextScale = scale > 1.5 ? 1 : 2.5;
|
setScale(next);
|
||||||
const ratio = nextScale / scale;
|
setOffset(off => ({ x: off.x - cx * (ratio - 1), y: off.y - cy * (ratio - 1) }));
|
||||||
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 onWheel = (e: React.WheelEvent) => {
|
const handleImageLoaded = () => {
|
||||||
e.preventDefault();
|
const img = imageRef.current;
|
||||||
const delta = -e.deltaY;
|
if (!img) return;
|
||||||
const zoomFactor = delta > 0 ? 1.12 : 0.88;
|
const stats = computeImageStats(img);
|
||||||
const cont = containerRef.current;
|
setHistogram(stats.histogram);
|
||||||
if (!cont) return;
|
setDominantColor(stats.dominantColor);
|
||||||
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 getTouchDistance = (t1: { clientX: number; clientY: number }, t2: { clientX: number; clientY: number }) =>
|
const switchEntry = (target: VfsEntry) => {
|
||||||
Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
|
const nextPath = joinPath(directory, target.name);
|
||||||
const onTouchStart = (e: React.TouchEvent) => {
|
setActiveEntry(target);
|
||||||
if (e.touches.length === 1) {
|
setActivePath(nextPath);
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
const switchRelative = (step: number) => {
|
||||||
return (
|
if (filmstrip.length <= 1) return;
|
||||||
<div style={{
|
const currentIndex = filmstrip.findIndex(item => item.name === activeEntry.name);
|
||||||
width: '100%',
|
if (currentIndex === -1) return;
|
||||||
height: '100%',
|
const target = filmstrip[(currentIndex + step + filmstrip.length) % filmstrip.length];
|
||||||
display: 'flex',
|
if (target) switchEntry(target);
|
||||||
alignItems: 'center',
|
};
|
||||||
justifyContent: 'center',
|
|
||||||
background: 'rgba(20,20,20,0.8)',
|
const scaleLabel = `${(scale * 100).toFixed(scale >= 1 ? 0 : 1)}%`;
|
||||||
backdropFilter: 'blur(24px)'
|
|
||||||
}}>
|
const imageStyle: React.CSSProperties = {
|
||||||
<Spin />
|
maxWidth: '100%',
|
||||||
</div>
|
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,
|
||||||
if (err) {
|
cursor: isDragging ? 'grabbing' : scale > 1 ? 'grab' : 'zoom-in',
|
||||||
return (
|
willChange: 'transform',
|
||||||
<div style={{
|
};
|
||||||
color: 'var(--ant-color-error, #f5222d)',
|
|
||||||
padding: 16,
|
const controlsNode = (
|
||||||
background: 'rgba(20,20,20,0.8)',
|
<ViewerControls
|
||||||
backdropFilter: 'blur(24px)'
|
style={controlsStyle}
|
||||||
}}>
|
onPrev={() => switchRelative(-1)}
|
||||||
加载失败: {err}
|
onNext={() => switchRelative(1)}
|
||||||
</div>
|
onZoomIn={() => zoom(1.18)}
|
||||||
);
|
onZoomOut={() => zoom(0.82)}
|
||||||
}
|
onRotate={rotateImage}
|
||||||
if (!url) {
|
onReset={resetView}
|
||||||
return (
|
onFit={fitToScreen}
|
||||||
<div style={{
|
disableSwitch={filmstrip.length <= 1}
|
||||||
padding: 16,
|
/>
|
||||||
background: 'rgba(20,20,20,0.8)',
|
);
|
||||||
backdropFilter: 'blur(24px)'
|
|
||||||
}}>
|
const exif = (stat?.exif ?? {}) as Record<string, unknown>;
|
||||||
无内容
|
const infoIconStyle: React.CSSProperties = { fontSize: 15, color: 'rgba(255,255,255,0.62)' };
|
||||||
</div>
|
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 (
|
return (
|
||||||
<div
|
<div style={containerStyle}>
|
||||||
ref={containerRef}
|
<section style={viewerStyles.main}>
|
||||||
onWheel={onWheel}
|
<div style={mainBackdropStyle} />
|
||||||
onMouseMove={onMouseMove}
|
<div style={viewerStyles.mainContent}>
|
||||||
onMouseUp={onMouseUp}
|
<ImageCanvas
|
||||||
onMouseLeave={onMouseUp}
|
containerRef={containerRef}
|
||||||
onMouseDown={onMouseDown}
|
imageRef={imageRef}
|
||||||
onDoubleClick={onDoubleClick}
|
viewerStyle={viewerStyle}
|
||||||
onTouchStart={onTouchStart}
|
controls={controlsNode}
|
||||||
onTouchMove={onTouchMove}
|
scaleLabel={scaleLabel}
|
||||||
onTouchEnd={onTouchEnd}
|
imageStyle={imageStyle}
|
||||||
style={{
|
loading={loading}
|
||||||
width: '100%',
|
error={error}
|
||||||
height: '100%',
|
imageUrl={imageUrl}
|
||||||
overflow: 'hidden',
|
activeEntry={activeEntry}
|
||||||
position: 'relative',
|
onRequestClose={onRequestClose}
|
||||||
background: 'rgba(20,20,20,0.8)',
|
onImageLoad={handleImageLoaded}
|
||||||
backdropFilter: 'blur(24px)',
|
onWheel={onWheel}
|
||||||
display: 'flex',
|
onMouseDown={onMouseDown}
|
||||||
alignItems: 'center',
|
onMouseMove={onMouseMove}
|
||||||
justifyContent: 'center',
|
onMouseLeave={stopDragging}
|
||||||
userSelect: 'none',
|
onMouseUp={stopDragging}
|
||||||
touchAction: 'none'
|
onDoubleClick={onDoubleClick}
|
||||||
}}
|
onTouchStart={onTouchStart}
|
||||||
>
|
onTouchMove={onTouchMove}
|
||||||
{/* 顶部栏:文件名和关闭按钮 */}
|
onTouchEnd={onTouchEnd}
|
||||||
<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'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 图片居中显示 */}
|
<Filmstrip
|
||||||
<div style={{
|
shellStyle={filmstripShellStyle}
|
||||||
position: 'relative',
|
listStyle={viewerStyles.filmstrip}
|
||||||
width: '100%',
|
entries={filmstrip}
|
||||||
height: '100%',
|
activeEntry={activeEntry}
|
||||||
display: 'flex',
|
onSelect={switchEntry}
|
||||||
alignItems: 'center',
|
filmstripRefs={filmstripRefs}
|
||||||
justifyContent: 'center',
|
pageInfo={pageInfo}
|
||||||
flexDirection: 'column'
|
getThumbUrl={getThumbUrl}
|
||||||
}}>
|
/>
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,6 +2,20 @@ import { useState, useCallback } from 'react';
|
|||||||
import { useNavigate, useLocation } from 'react-router';
|
import { useNavigate, useLocation } from 'react-router';
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
import { vfsApi, type VfsEntry } from '../../../api/client';
|
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';
|
import { processorsApi, type ProcessorTypeMeta } from '../../../api/processors';
|
||||||
|
|
||||||
export function useFileExplorer(navKey: string) {
|
export function useFileExplorer(navKey: string) {
|
||||||
@@ -34,7 +48,8 @@ export function useFileExplorer(navKey: string) {
|
|||||||
processorsApi.list()
|
processorsApi.list()
|
||||||
]);
|
]);
|
||||||
setEntries(res.entries);
|
setEntries(res.entries);
|
||||||
setPath(res.path || canonical);
|
const resolvedPath = res.path || canonical;
|
||||||
|
setPath(resolvedPath);
|
||||||
setPagination(prev => ({
|
setPagination(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
current: res.pagination!.page,
|
current: res.pagination!.page,
|
||||||
@@ -42,8 +57,22 @@ export function useFileExplorer(navKey: string) {
|
|||||||
total: res.pagination!.total
|
total: res.pagination!.total
|
||||||
}));
|
}));
|
||||||
setProcessorTypes(processors);
|
setProcessorTypes(processors);
|
||||||
} catch (e: any) {
|
if (typeof window !== 'undefined') {
|
||||||
message.error(e.message || 'Load failed');
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user