diff --git a/web/src/apps/ImageViewer/ImageViewer.tsx b/web/src/apps/ImageViewer/ImageViewer.tsx index 2aef1a1..df6dc4b 100644 --- a/web/src/apps/ImageViewer/ImageViewer.tsx +++ b/web/src/apps/ImageViewer/ImageViewer.tsx @@ -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; +} + +declare global { + interface WindowEventMap { + 'foxel:file-explorer-page': CustomEvent; + } +} +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 = ({ filePath, entry, onRequestClose }) => { - const [url, setUrl] = useState(); + const normalizedInitialPath = filePath.startsWith('/') ? filePath : `/${filePath}`; + const [activeEntry, setActiveEntry] = useState(entry); + const [activePath, setActivePath] = useState(normalizedInitialPath); + const [imageUrl, setImageUrl] = useState(); const [loading, setLoading] = useState(true); - const [err, setErr] = useState(); + const [error, setError] = useState(); + const [stat, setStat] = useState(null); + const [histogram, setHistogram] = useState(null); + const [dominantColor, setDominantColor] = useState(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(null); + const [isDragging, setIsDragging] = useState(false); + const [filmstrip, setFilmstrip] = useState([]); + const [pageInfo, setPageInfo] = useState<{ page: number; total: number; pageSize: number } | null>(null); + const containerRef = useRef(null); - const lastPointer = useRef<{ x: number; y: number } | null>(null); - const lastDistance = useRef(null); + const imageRef = useRef(null); + const dragPointRef = useRef<{ x: number; y: number } | null>(null); + const pinchDistanceRef = useRef(null); const transitionRef = useRef(false); + const filmstripRefs = useRef>({}); + + const directory = useMemo(() => getDirectory(activePath), [activePath]); + + const baseTone = useMemo(() => 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, + ]) + .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 ( -
- -
- ); - } - if (err) { - return ( -
- 加载失败: {err} -
- ); - } - if (!url) { - return ( -
- 无内容 -
- ); - } + 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 = ( + 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; + 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: }, + { label: '文件大小', value: humanFileSize(stat?.size), icon: }, + { label: '分辨率', value: width && height ? `${width} × ${height}` : null, icon: }, + { label: '颜色空间', value: colorSpace ?? null, icon: }, + { label: '修改时间', value: stat?.mtime ? formatDateTime(stat.mtime) : null, icon: }, + { label: '路径', value: typeof stat?.path === 'string' ? stat.path : activePath, icon: }, + ]; + + const shootingList: InfoItem[] = [ + { label: '焦距', value: focalLength, icon: }, + { label: '光圈', value: aperture, icon: }, + { label: '快门', value: exposure, icon: }, + { label: 'ISO', value: isoValue != null ? isoValue.toString() : null, icon: }, + ]; + + const deviceList: InfoItem[] = [ + { + label: '相机', + value: cameraModel ? `${cameraMake ? `${cameraMake} ` : ''}${cameraModel}` : (cameraMake ?? null), + icon: , + }, + { label: '镜头', value: lensModel ?? null, icon: }, + ]; + + const miscList: InfoItem[] = [ + { label: '拍摄时间', value: captureTime, icon: }, + ]; return ( -
- {/* 顶部栏:文件名和关闭按钮 */} -
- - {entry.name} ({(entry.size / 1024).toFixed(1)} KB) - - -
-
+ + + ); }; diff --git a/web/src/apps/ImageViewer/components/Filmstrip.tsx b/web/src/apps/ImageViewer/components/Filmstrip.tsx new file mode 100644 index 0000000..61fa5eb --- /dev/null +++ b/web/src/apps/ImageViewer/components/Filmstrip.tsx @@ -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>; + pageInfo: PageInfo | null; + getThumbUrl: (entry: VfsEntry) => string; +} + +export const Filmstrip: React.FC = ({ + shellStyle, + listStyle, + entries, + activeEntry, + onSelect, + filmstripRefs, + pageInfo, + getThumbUrl, +}) => ( +
+
+ + 胶片带 · {entries.length} 张 + + {pageInfo && ( + + 第 {pageInfo.page} 页 / 共 {Math.max(1, Math.ceil(pageInfo.total / pageInfo.pageSize))} 页 + + )} +
+
+ {entries.map(item => { + const active = item.name === activeEntry.name; + return ( +
{ 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', + }} + > + {item.name} + {active && ( +
+ {item.name} +
+ )} +
+ ); + })} + {entries.length === 0 && ( +
暂无图片
+ )} +
+
+); diff --git a/web/src/apps/ImageViewer/components/ImageCanvas.tsx b/web/src/apps/ImageViewer/components/ImageCanvas.tsx new file mode 100644 index 0000000..7dd1cd1 --- /dev/null +++ b/web/src/apps/ImageViewer/components/ImageCanvas.tsx @@ -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; + imageRef: React.RefObject; + 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; + onMouseDown: React.MouseEventHandler; + onMouseMove: React.MouseEventHandler; + onMouseLeave: React.MouseEventHandler; + onMouseUp: React.MouseEventHandler; + onDoubleClick: React.MouseEventHandler; + onTouchStart: React.TouchEventHandler; + onTouchMove: React.TouchEventHandler; + onTouchEnd: React.TouchEventHandler; +} + +export const ImageCanvas: React.FC = ({ + containerRef, + imageRef, + viewerStyle, + controls, + scaleLabel, + imageStyle, + loading, + error, + imageUrl, + activeEntry, + onRequestClose, + onImageLoad, + onWheel, + onMouseDown, + onMouseMove, + onMouseLeave, + onMouseUp, + onDoubleClick, + onTouchStart, + onTouchMove, + onTouchEnd, +}) => ( +
+
+ +
+ {loading ? ( + + ) : error ? ( + {error} + ) : imageUrl ? ( + {activeEntry.name} + ) : ( + 无可用内容 + )} + +
{scaleLabel}
+ + {controls} +
+); diff --git a/web/src/apps/ImageViewer/components/InfoPanel.tsx b/web/src/apps/ImageViewer/components/InfoPanel.tsx new file mode 100644 index 0000000..304f663 --- /dev/null +++ b/web/src/apps/ImageViewer/components/InfoPanel.tsx @@ -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 }) => ( + + {children} + +); + +const HistogramPlot: React.FC<{ data: HistogramData | null }> = ({ data }) => { + if (!data) { + return ; + } + 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 ( + + + + + + + ); +}; + +const InfoRows: React.FC<{ items: InfoItem[] }> = ({ items }) => ( +
+ {items + .filter(item => item.value !== null && item.value !== undefined && item.value !== '') + .map(item => ( + + + {item.icon && {item.icon}} + {item.label} + + {item.value} + + ))} +
+); + +export const InfoPanel: React.FC = ({ + style, + histogramCardStyle, + title, + captureTime, + basicList, + shootingList, + deviceList, + miscList, + histogram, +}) => ( + +); diff --git a/web/src/apps/ImageViewer/components/ViewerControls.tsx b/web/src/apps/ImageViewer/components/ViewerControls.tsx new file mode 100644 index 0000000..ad43e56 --- /dev/null +++ b/web/src/apps/ImageViewer/components/ViewerControls.tsx @@ -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 = ({ + style, + onPrev, + onNext, + onZoomIn, + onZoomOut, + onRotate, + onReset, + onFit, + disableSwitch, +}) => ( +
+ +
+); diff --git a/web/src/apps/ImageViewer/components/types.ts b/web/src/apps/ImageViewer/components/types.ts new file mode 100644 index 0000000..efa01d3 --- /dev/null +++ b/web/src/apps/ImageViewer/components/types.ts @@ -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; +} diff --git a/web/src/apps/ImageViewer/styles.ts b/web/src/apps/ImageViewer/styles.ts new file mode 100644 index 0000000..72ea357 --- /dev/null +++ b/web/src/apps/ImageViewer/styles.ts @@ -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', + }, +}; diff --git a/web/src/pages/FileExplorerPage/hooks/useFileExplorer.ts b/web/src/pages/FileExplorerPage/hooks/useFileExplorer.ts index ab2399e..f518c02 100644 --- a/web/src/pages/FileExplorerPage/hooks/useFileExplorer.ts +++ b/web/src/pages/FileExplorerPage/hooks/useFileExplorer.ts @@ -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('foxel:file-explorer-page', { detail: snapshot })); + } + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Load failed'; + message.error(msg); } finally { setLoading(false); }