Basic Picture Preview Impl

This commit is contained in:
2026-04-12 23:01:28 +08:00
commit cc4ccac5b1
47 changed files with 10400 additions and 0 deletions

98
src/App.css Normal file
View File

@@ -0,0 +1,98 @@
:root {
color: #17221d;
background:
radial-gradient(circle at top left, rgba(250, 225, 177, 0.7), transparent 28%),
radial-gradient(circle at bottom right, rgba(77, 138, 116, 0.22), transparent 24%),
linear-gradient(145deg, #f5efe6 0%, #ebe4d7 100%);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html,
body,
#root {
margin: 0;
height: 100%;
overflow: hidden;
}
* {
box-sizing: border-box;
}
/* Two-panel layout */
.app-layout {
display: flex;
height: 100vh;
overflow: hidden;
}
.app-sidebar {
width: 360px;
min-width: 200px;
max-width: 600px;
display: flex;
flex-direction: column;
border-right: 1px solid rgba(23, 34, 29, 0.12);
background: rgba(255, 251, 244, 0.85);
backdrop-filter: blur(12px);
overflow: hidden;
}
.app-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: transparent;
}
/* FileExplorer */
.file-explorer {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.file-explorer__toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 6px 8px;
border-bottom: 1px solid rgba(23, 34, 29, 0.08);
background: rgba(255, 253, 248, 0.9);
flex-shrink: 0;
}
.file-explorer .ant-table-wrapper {
flex: 1;
overflow: hidden;
}
.file-explorer .ant-table-wrapper,
.file-explorer .ant-spin-nested-loading,
.file-explorer .ant-spin-container,
.file-explorer .ant-table,
.file-explorer .ant-table-container {
height: 100%;
}
.file-explorer .ant-table-body {
overflow-y: auto !important;
}
.file-explorer__row--selected td {
background: rgba(22, 119, 255, 0.1) !important;
}
.file-explorer .ant-table-row:hover td {
background: rgba(23, 34, 29, 0.04) !important;
}
.file-explorer__row--selected:hover td {
background: rgba(22, 119, 255, 0.15) !important;
}

56
src/App.tsx Normal file
View File

@@ -0,0 +1,56 @@
import { App as AntApp } from "antd";
import { getStartDirectory } from "./api";
import { FileExplorer } from "./components/FileExplorer";
import { ImagePreviewPanel } from "./components/ImagePreviewPanel";
import { useEffect, useState } from "react";
import { Entry, FileOptions } from "./types";
import "./App.css";
interface PreviewTarget {
path: string;
options?: FileOptions[];
}
function AppContent() {
const [startDirectory, setStartDirectory] = useState<string>("");
const [preview, setPreview] = useState<PreviewTarget | null>(null);
useEffect(() => {
getStartDirectory().then(setStartDirectory);
}, []);
const handleEntrySelect = (entry: Entry, fullPath: string, options?: FileOptions[]) => {
if (entry.entry_type === 'Image') {
setPreview({ path: fullPath, options });
} else {
setPreview(null);
}
};
return (
<div className="app-layout">
<div className="app-sidebar">
{startDirectory && (
<FileExplorer initialPath={startDirectory} onEntrySelect={handleEntrySelect} />
)}
</div>
<div className="app-content">
{preview ? (
<ImagePreviewPanel path={preview.path} options={preview.options} />
) : (
<span style={{ color: "#8c8c8c" }}></span>
)}
</div>
</div>
);
}
function App() {
return (
<AntApp>
<AppContent />
</AntApp>
);
}
export default App;

19
src/api.ts Normal file
View File

@@ -0,0 +1,19 @@
import { invoke } from "@tauri-apps/api/core";
import { GameTitle, Entry, FileOptions } from "./types";
export async function getStartDirectory(): Promise<string> {
return await invoke("get_start_directory");
}
export async function getXp3SupportedGames(): Promise<GameTitle[]> {
return await invoke("get_xp3_supported_games");
}
export async function listDirectory(path: string, options?: FileOptions[]): Promise<Entry[]> {
return await invoke("list_directory", { path, options: options ?? null });
}
export async function previewImage(path: string, options?: FileOptions[]): Promise<Uint8Array> {
const bytes: number[] = await invoke("preview_image", { path, options: options ?? null });
return new Uint8Array(bytes);
}

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,390 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Input, Button, Tooltip, Table, App as AntApp } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
FolderOutlined,
FileOutlined,
FileImageOutlined,
SoundOutlined,
CompressOutlined,
FileTextOutlined,
ArrowLeftOutlined,
ArrowRightOutlined,
ArrowUpOutlined,
} from '@ant-design/icons';
import { Entry, EntryType, FileOptions } from '../types';
import { listDirectory } from '../api';
import { Xp3OptionsDialog } from './Xp3OptionsDialog';
interface FileExplorerProps {
initialPath: string;
onEntrySelect?: (entry: Entry, fullPath: string, options?: FileOptions[]) => void;
}
/**
* A location in the navigation history.
* archiveSubdir === undefined -> FS directory (always call backend)
* archiveSubdir === "" -> root of an archive
* archiveSubdir === "bg/" -> virtual subdir inside an archive
*
* backendPath for nested archives: "game.xp3|inner.dat"
* options[N] corresponds to the N-th archive in the pipe-separated backendPath
*/
interface Location {
backendPath: string;
archiveSubdir?: string;
options?: FileOptions[];
}
function locationDisplayPath(loc: Location): string {
if (loc.archiveSubdir === undefined || loc.archiveSubdir === '') return loc.backendPath;
return `${loc.backendPath}|${loc.archiveSubdir}`;
}
function parsePath(path: string): Location {
const pipeIdx = path.indexOf('|');
if (pipeIdx === -1) return { backendPath: path, archiveSubdir: undefined };
return { backendPath: path.slice(0, pipeIdx), archiveSubdir: path.slice(pipeIdx + 1) };
}
function fsDirParent(path: string): string | null {
const norm = path.replace(/\\/g, '/').replace(/\/+$/, '');
const lastSlash = norm.lastIndexOf('/');
if (lastSlash === -1) return null;
if (lastSlash === 0) return '/';
const parent = norm.slice(0, lastSlash);
if (/^[A-Za-z]:$/.test(parent)) return parent + '/';
return parent;
}
function archiveSubdirParent(subdir: string): string | null {
if (subdir === '') return null;
const trimmed = subdir.replace(/\/$/, '');
const lastSlash = trimmed.lastIndexOf('/');
if (lastSlash === -1) return '';
return trimmed.slice(0, lastSlash + 1);
}
function getParentLocation(loc: Location): Location | null {
if (loc.archiveSubdir === undefined) {
const parent = fsDirParent(loc.backendPath);
return parent ? { backendPath: parent } : null;
}
const parentSubdir = archiveSubdirParent(loc.archiveSubdir);
if (parentSubdir !== null) return { ...loc, archiveSubdir: parentSubdir };
const archiveParent = fsDirParent(loc.backendPath);
return archiveParent ? { backendPath: archiveParent } : null;
}
function joinFsPath(dir: string, name: string): string {
return dir.replace(/[/\\]+$/, '') + '/' + name;
}
function sortEntries(entries: Entry[]): Entry[] {
return [...entries].sort((a, b) => {
const aIsFolder = a.entry_type === 'Folder' ? 0 : 1;
const bIsFolder = b.entry_type === 'Folder' ? 0 : 1;
if (aIsFolder !== bIsFolder) return aIsFolder - bIsFolder;
// 文件夹之间按名字排序,其他类型保持原始顺序
if (aIsFolder === 0) return a.name.localeCompare(b.name);
return 0;
});
}
/**
* Convert a flat archive listing into entries visible at the given virtual subdir.
* virtualSubdir: "" = root, "bg/" = inside bg folder
*/
function buildVirtualDirEntries(flatEntries: Entry[], virtualSubdir: string): Entry[] {
const result = new Map<string, Entry>();
for (const entry of flatEntries) {
const fullName = entry.name.replace(/\\/g, '/');
if (!fullName.startsWith(virtualSubdir)) continue;
const rest = fullName.slice(virtualSubdir.length);
if (rest === '') continue;
const slashIdx = rest.indexOf('/');
if (slashIdx === -1) {
result.set(rest, { ...entry, name: rest });
} else {
const folderName = rest.slice(0, slashIdx);
if (!result.has(folderName)) {
result.set(folderName, {
name: folderName,
is_dir: true,
entry_type: 'Folder',
msg_tool_type: undefined,
size: undefined,
});
}
}
}
return sortEntries(Array.from(result.values()));
}
function getEntryIcon(entryType: EntryType): React.ReactNode {
switch (entryType) {
case 'Folder': return <FolderOutlined style={{ color: '#faad14' }} />;
case 'Image': return <FileImageOutlined style={{ color: '#52c41a' }} />;
case 'Audio': return <SoundOutlined style={{ color: '#1677ff' }} />;
case 'Archive': return <CompressOutlined style={{ color: '#eb2f96' }} />;
case 'Text': return <FileTextOutlined style={{ color: '#722ed1' }} />;
default: return <FileOutlined style={{ color: '#8c8c8c' }} />;
}
}
function getTypeLabel(entryType: EntryType): string {
switch (entryType) {
case 'Folder': return '文件夹';
case 'Archive': return '归档';
case 'Image': return '图片';
case 'Audio': return '音频';
case 'Text': return '文本';
default: return '未知';
}
}
function formatSize(size?: number): string {
if (size == null) return '';
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
interface NavState {
history: Location[];
index: number;
}
export function FileExplorer({ initialPath, onEntrySelect }: FileExplorerProps) {
const { message: messageApi } = AntApp.useApp();
const [nav, setNav] = useState<NavState>({
history: [{ backendPath: initialPath, archiveSubdir: undefined }],
index: 0,
});
const currentLoc = nav.history[nav.index];
const [pathInput, setPathInput] = useState(initialPath);
const [entries, setEntries] = useState<Entry[]>([]);
const [loading, setLoading] = useState(false);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const archiveCache = useRef<Map<string, Entry[]>>(new Map());
// Pending navigation for XP3 dialog
const [xp3DialogOpen, setXp3DialogOpen] = useState(false);
const pendingNavRef = useRef<{ target: Location; currentOptions?: FileOptions[] } | null>(null);
const loadLocation = useCallback(async (loc: Location) => {
setLoading(true);
setSelectedKey(null);
try {
if (loc.archiveSubdir !== undefined) {
const cacheKey = loc.options
? `${loc.backendPath}::${JSON.stringify(loc.options)}`
: loc.backendPath;
let flat = archiveCache.current.get(cacheKey);
if (!flat) {
flat = await listDirectory(loc.backendPath, loc.options);
archiveCache.current.set(cacheKey, flat);
}
setEntries(buildVirtualDirEntries(flat, loc.archiveSubdir));
} else {
const result = await listDirectory(loc.backendPath);
setEntries(sortEntries(result));
}
} catch (err: unknown) {
const e = err as { msg?: string };
messageApi.error(`无法打开: ${e?.msg ?? String(err)}`);
setEntries([]);
} finally {
setLoading(false);
}
}, [messageApi]);
useEffect(() => {
setPathInput(locationDisplayPath(currentLoc));
loadLocation(currentLoc);
}, [currentLoc, loadLocation]);
const navigateTo = useCallback((loc: Location) => {
setNav(prev => ({
history: [...prev.history.slice(0, prev.index + 1), loc],
index: prev.index + 1,
}));
}, []);
const goBack = useCallback(() => {
setNav(prev => prev.index > 0 ? { ...prev, index: prev.index - 1 } : prev);
}, []);
const goForward = useCallback(() => {
setNav(prev => prev.index < prev.history.length - 1 ? { ...prev, index: prev.index + 1 } : prev);
}, []);
const goUp = useCallback(() => {
const parent = getParentLocation(currentLoc);
if (parent) navigateTo(parent);
}, [currentLoc, navigateTo]);
const handlePathSubmit = useCallback(() => {
const trimmed = pathInput.trim();
const current = locationDisplayPath(currentLoc);
if (trimmed && trimmed !== current) {
navigateTo(parsePath(trimmed));
} else {
loadLocation(currentLoc);
}
}, [pathInput, currentLoc, navigateTo, loadLocation]);
const handleRowClick = useCallback((entry: Entry) => {
setSelectedKey(entry.name);
let fullPath: string;
if (currentLoc.archiveSubdir !== undefined) {
fullPath = `${currentLoc.backendPath}|${currentLoc.archiveSubdir}${entry.name}`;
} else {
fullPath = joinFsPath(currentLoc.backendPath, entry.name);
}
onEntrySelect?.(entry, fullPath, currentLoc.options);
}, [currentLoc, onEntrySelect]);
const handleRowDoubleClick = useCallback((entry: Entry) => {
if (currentLoc.archiveSubdir !== undefined) {
if (entry.entry_type === 'Folder') {
navigateTo({ ...currentLoc, archiveSubdir: currentLoc.archiveSubdir + entry.name + '/' });
} else if (entry.entry_type === 'Archive') {
const innerPath = currentLoc.archiveSubdir + entry.name;
const targetLoc: Location = { backendPath: `${currentLoc.backendPath}|${innerPath}`, archiveSubdir: '' };
if (entry.msg_tool_type === 'KirikiriXp3') {
pendingNavRef.current = { target: targetLoc, currentOptions: currentLoc.options };
setXp3DialogOpen(true);
} else {
navigateTo(targetLoc);
}
}
} else {
if (entry.entry_type === 'Folder') {
navigateTo({ backendPath: joinFsPath(currentLoc.backendPath, entry.name) });
} else if (entry.entry_type === 'Archive') {
const targetLoc: Location = { backendPath: joinFsPath(currentLoc.backendPath, entry.name), archiveSubdir: '' };
if (entry.msg_tool_type === 'KirikiriXp3') {
pendingNavRef.current = { target: targetLoc, currentOptions: currentLoc.options };
setXp3DialogOpen(true);
} else {
navigateTo(targetLoc);
}
}
}
}, [currentLoc, navigateTo]);
const handleXp3Confirm = useCallback((options: FileOptions | null) => {
setXp3DialogOpen(false);
const pending = pendingNavRef.current;
pendingNavRef.current = null;
if (!pending) return;
if (options) {
const { target, currentOptions } = pending;
// Index of the new archive in the pipe-separated backendPath (0-based)
const idx = target.backendPath.split('|').length - 1;
const opts: FileOptions[] = [...(currentOptions ?? [])];
while (opts.length < idx) opts.push({});
opts[idx] = options;
navigateTo({ ...target, options: opts });
} else {
navigateTo(pending.target);
}
}, [navigateTo]);
const handleXp3Cancel = useCallback(() => {
setXp3DialogOpen(false);
pendingNavRef.current = null;
}, []);
const canGoBack = nav.index > 0;
const canGoForward = nav.index < nav.history.length - 1;
const canGoUp = getParentLocation(currentLoc) !== null;
const columns: ColumnsType<Entry> = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
ellipsis: true,
render: (name: string, record) => (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{getEntryIcon(record.entry_type)}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{name}
</span>
</span>
),
},
{
title: '类型',
dataIndex: 'entry_type',
key: 'entry_type',
width: 60,
render: (type: EntryType) => (
<span style={{ color: '#8c8c8c', fontSize: 12 }}>{getTypeLabel(type)}</span>
),
},
{
title: '大小',
dataIndex: 'size',
key: 'size',
width: 75,
align: 'right' as const,
render: (size?: number) => (
<span style={{ color: '#8c8c8c', fontSize: 12 }}>{formatSize(size)}</span>
),
},
];
return (
<div className="file-explorer">
<Xp3OptionsDialog
open={xp3DialogOpen}
onConfirm={handleXp3Confirm}
onCancel={handleXp3Cancel}
/>
<div className="file-explorer__toolbar">
<Tooltip title="后退">
<Button size="small" type="text" icon={<ArrowLeftOutlined />}
disabled={!canGoBack} onClick={goBack} />
</Tooltip>
<Tooltip title="前进">
<Button size="small" type="text" icon={<ArrowRightOutlined />}
disabled={!canGoForward} onClick={goForward} />
</Tooltip>
<Tooltip title="上一级">
<Button size="small" type="text" icon={<ArrowUpOutlined />}
disabled={!canGoUp} onClick={goUp} />
</Tooltip>
<Input
size="small"
value={pathInput}
onChange={e => setPathInput(e.target.value)}
onPressEnter={handlePathSubmit}
onBlur={handlePathSubmit}
style={{ flex: 1, fontFamily: 'monospace', fontSize: 12 }}
/>
</div>
<Table<Entry>
size="small"
columns={columns}
dataSource={entries}
rowKey="name"
loading={loading}
pagination={false}
scroll={{ y: 'calc(100vh - 80px)' }}
rowClassName={record => record.name === selectedKey ? 'file-explorer__row--selected' : ''}
onRow={record => ({
onClick: () => handleRowClick(record),
onDoubleClick: () => handleRowDoubleClick(record),
style: { cursor: 'default' },
})}
/>
</div>
);
}

View File

@@ -0,0 +1,308 @@
import React, { useEffect, useRef, useState } from 'react';
import { Spin, Button, Tooltip, Space } from 'antd';
import { CompressOutlined, BorderOuterOutlined } from '@ant-design/icons';
import { FileOptions } from '../types';
import { previewImage } from '../api';
interface ImagePreviewPanelProps {
path: string;
options?: FileOptions[];
}
type ScaleMode = 'fit' | '100%' | 'fill' | 'custom';
export function ImagePreviewPanel({ path, options }: ImagePreviewPanelProps) {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [imageSize, setImageSize] = useState<{ w: number, h: number } | null>(null);
const [containerSize, setContainerSize] = useState<{ w: number, h: number } | null>(null);
const [scaleMode, setScaleMode] = useState<ScaleMode>('fit');
const [customScale, setCustomScale] = useState<number>(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const prevUrlRef = useRef<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const dragStart = useRef({ mouseX: 0, mouseY: 0, imgX: 0, imgY: 0 });
// Load image
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
setObjectUrl(null);
setImageSize(null);
setScaleMode('fit');
setCustomScale(1);
setPosition({ x: 0, y: 0 });
previewImage(path, options)
.then(bytes => {
if (cancelled) return;
const blob = new Blob([bytes as any], { type: 'image/png' });
const url = URL.createObjectURL(blob);
if (prevUrlRef.current) URL.revokeObjectURL(prevUrlRef.current);
prevUrlRef.current = url;
setObjectUrl(url);
})
.catch(err => {
if (cancelled) return;
const msg = (err as { msg?: string })?.msg ?? String(err);
setError(msg);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [path, options]);
// Cleanup object URL
useEffect(() => {
return () => {
if (prevUrlRef.current) URL.revokeObjectURL(prevUrlRef.current);
};
}, []);
// Track container size
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerSize({
w: entry.contentRect.width,
h: entry.contentRect.height
});
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [objectUrl]);
// Drag logic
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current) return;
setPosition({
x: dragStart.current.imgX + (e.clientX - dragStart.current.mouseX),
y: dragStart.current.imgY + (e.clientY - dragStart.current.mouseY),
});
};
const handleMouseUp = () => {
isDragging.current = false;
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, []);
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
const target = e.target as HTMLImageElement;
setImageSize({ w: target.naturalWidth, h: target.naturalHeight });
};
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
isDragging.current = true;
dragStart.current = {
mouseX: e.clientX,
mouseY: e.clientY,
imgX: position.x,
imgY: position.y,
};
};
const getScale = (
mode = scaleMode,
cScale = customScale,
iSize = imageSize,
cSize = containerSize
) => {
if (!iSize || !cSize) return 1;
const { w, h } = iSize;
const { w: cw, h: ch } = cSize;
if (w === 0 || h === 0 || cw === 0 || ch === 0) return 1;
const rw = cw / w;
const rh = ch / h;
if (mode === 'fit') return Math.min(1, rw, rh); // 只缩小不放大
if (mode === 'fill') return Math.max(rw, rh); // 可以放大超过100%
if (mode === '100%') return 1;
return cScale;
};
const currentScale = getScale();
const stateRef = useRef({ imageSize, containerSize, scaleMode, customScale, position });
stateRef.current = { imageSize, containerSize, scaleMode, customScale, position };
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const state = stateRef.current;
const scaleStr = getScale(state.scaleMode, state.customScale, state.imageSize, state.containerSize);
const scaleFactor = 1.15;
const delta = e.deltaY < 0 ? 1 : -1;
let newScale = scaleStr * (delta > 0 ? scaleFactor : 1 / scaleFactor);
newScale = Math.max(0.05, Math.min(newScale, 100)); // 缩放范围限制 5% 到 10000%
const rect = container.getBoundingClientRect();
const cx = rect.width / 2;
const cy = rect.height / 2;
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const dx = mouseX - cx;
const dy = mouseY - cy;
const scaleRatio = newScale / scaleStr;
setPosition({
x: dx - (dx - state.position.x) * scaleRatio,
y: dy - (dy - state.position.y) * scaleRatio,
});
setCustomScale(newScale);
setScaleMode('custom');
};
container.addEventListener('wheel', handleWheel, { passive: false });
return () => container.removeEventListener('wheel', handleWheel);
}, [objectUrl]);
const resetPositionAndSetMode = (mode: ScaleMode) => {
setScaleMode(mode);
setPosition({ x: 0, y: 0 }); // Switch mode resets the drag position to center
};
if (loading && !objectUrl) {
return (
<div style={centerStyle}>
<Spin size="large" />
</div>
);
}
if (error) {
return (
<div style={centerStyle}>
<span style={{ color: '#ff4d4f', fontSize: 13 }}>{error}</span>
</div>
);
}
if (!objectUrl) return null;
return (
<div style={containerStyle} ref={containerRef}>
<img
src={objectUrl!}
alt={path.split('|').pop()?.split(/[/\\]/).pop() ?? ''}
style={{
...imgStyle,
transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px)) scale(${currentScale})`,
cursor: 'grab',
}}
draggable={false}
onClick={(e) => e.stopPropagation()}
onMouseDown={handleMouseDown}
onLoad={handleImageLoad}
/>
{/* Toolbar */}
<div style={toolbarContainerStyle}>
<Space size={8} style={{ padding: '6px 12px', background: 'rgba(255, 255, 255, 0.85)', backdropFilter: 'blur(8px)', borderRadius: 6, boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }}>
<Tooltip title="适应窗口 (默认只缩小不放大)">
<Button
type={scaleMode === 'fit' ? 'primary' : 'text'}
icon={<CompressOutlined />}
onClick={() => resetPositionAndSetMode('fit')}
size="small"
/>
</Tooltip>
<Tooltip title="100% 大小">
<Button
type={scaleMode === '100%' ? 'primary' : 'text'}
icon={<span style={{ fontSize: 12, fontWeight: 'bold' }}>1:1</span>}
onClick={() => resetPositionAndSetMode('100%')}
size="small"
/>
</Tooltip>
<Tooltip title="填充窗口">
<Button
type={scaleMode === 'fill' ? 'primary' : 'text'}
icon={<BorderOuterOutlined />}
onClick={() => resetPositionAndSetMode('fill')}
size="small"
/>
</Tooltip>
</Space>
</div>
{/* Info Status */}
<div style={infoContainerStyle}>
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.7)', fontWeight: 'bold', fontFamily: 'monospace' }}>
{Math.round(currentScale * 100)}%
{imageSize && ` | ${imageSize.w} x ${imageSize.h}`}
</span>
</div>
</div>
);
}
const centerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
};
const containerStyle: React.CSSProperties = {
position: 'relative',
width: '100%',
height: '100%',
overflow: 'hidden',
padding: 0,
};
const imgStyle: React.CSSProperties = {
position: 'absolute',
top: '50%',
left: '50%',
transformOrigin: 'center',
imageRendering: 'pixelated',
borderRadius: 0,
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
willChange: 'transform',
};
const toolbarContainerStyle: React.CSSProperties = {
position: 'absolute',
top: 16,
right: 16,
zIndex: 10,
};
const infoContainerStyle: React.CSSProperties = {
position: 'absolute',
bottom: 16,
right: 16,
zIndex: 10,
background: 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'blur(8px)',
padding: '4px 8px',
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
};

View File

@@ -0,0 +1,92 @@
import { useState, useEffect } from 'react';
import { Modal, Select, Switch, Form } from 'antd';
import { GameTitle, FileOptions } from '../types';
import { getXp3SupportedGames } from '../api';
interface Xp3OptionsDialogProps {
open: boolean;
onConfirm: (options: FileOptions | null) => void;
onCancel: () => void;
}
export function Xp3OptionsDialog({ open, onConfirm, onCancel }: Xp3OptionsDialogProps) {
const [games, setGames] = useState<GameTitle[]>([]);
const [gamesLoading, setGamesLoading] = useState(false);
const [enabled, setEnabled] = useState(false);
const [gameTitle, setGameTitle] = useState<string | undefined>(undefined);
const [forceDecrypt, setForceDecrypt] = useState(false);
useEffect(() => {
if (open) {
setEnabled(false);
setGameTitle(undefined);
setForceDecrypt(false);
if (games.length === 0) {
setGamesLoading(true);
getXp3SupportedGames().then(g => {
setGames(g);
setGamesLoading(false);
});
}
}
}, [open]);
const handleOk = () => {
if (!enabled) {
onConfirm(null);
} else {
onConfirm({
xp3: {
game_title: gameTitle,
force_decrypt: forceDecrypt,
},
});
}
};
const gameOptions = games.map(g => {
const label = g.alias ? `${g.name} (${g.alias.join(' / ')})` : g.name;
return { value: g.name, label };
});
return (
<Modal
title="XP3 解密选项"
open={open}
onOk={handleOk}
onCancel={onCancel}
okText="确定"
cancelText="取消"
width={420}
>
<Form layout="vertical" style={{ marginTop: 16 }}>
<Form.Item label="启用解密">
<Switch checked={enabled} onChange={v => setEnabled(v)} />
</Form.Item>
<Form.Item label="游戏">
<Select
disabled={!enabled}
value={gameTitle}
onChange={setGameTitle}
options={gameOptions}
placeholder="选择游戏"
allowClear
showSearch
loading={gamesLoading}
filterOption={(input, option) =>
String(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="强制解密">
<Switch
disabled={!enabled}
checked={enabled && forceDecrypt}
onChange={v => setForceDecrypt(v)}
/>
</Form.Item>
</Form>
</Modal>
);
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "antd/dist/reset.css";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

28
src/types.ts Normal file
View File

@@ -0,0 +1,28 @@
export interface GameTitle {
name: string;
alias?: string[];
}
export type EntryType = 'Archive' | 'Text' | 'Image' | 'Audio' | 'Folder' | 'Unknown';
export interface Entry {
name: string;
is_dir: boolean;
entry_type: EntryType;
msg_tool_type?: string;
size?: number;
}
export interface FileOptions {
xp3?: {
game_title?: string;
force_decrypt: boolean;
};
}
export type ErrorType = 'NotFound' | 'Other';
export interface ErrorMsg {
typ: ErrorType;
msg: string;
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />