合并 WP4 资源面板与撤销重做:保留 C7 版本入口与资源面板接线
- project.rs 冲突:同时保留 mod asset_export(WS-B 下载命令)与 mod asset_rename(WS-D 重命名命令),丢弃已在 WS-A 退役的 asset_usage - index.tsx 冲突:同时保留 GameRunVersionPicker(C7 版本入口)与资源面板/历史模型导入(WP4)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main",
|
||||
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件。",
|
||||
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
|
||||
"windows": ["client"],
|
||||
"permissions": [
|
||||
"clipboard-manager:allow-read-image",
|
||||
@@ -21,6 +21,7 @@
|
||||
]
|
||||
},
|
||||
"opener:default",
|
||||
"dialog:allow-open"
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4332,6 +4332,12 @@ pub(crate) fn read_local_project_file(
|
||||
read_local_project_file_at(root, &normalized_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn save_local_project_asset_file(
|
||||
input: SaveLocalProjectAssetFileInput,
|
||||
) -> Result<SaveLocalProjectAssetFileResult, String> {
|
||||
save_local_project_asset_file_at(input)
|
||||
}
|
||||
#[tauri::command]
|
||||
pub(crate) async fn read_local_project_image_preview(
|
||||
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
|
||||
|
||||
@@ -2648,6 +2648,7 @@ fn main() {
|
||||
import_local_project_image_assets,
|
||||
read_local_project_file,
|
||||
read_local_project_image_preview,
|
||||
save_local_project_asset_file,
|
||||
read_local_project_text_preview,
|
||||
read_local_project_media_preview,
|
||||
cancel_local_project_resource_preview_scope,
|
||||
|
||||
@@ -4,6 +4,7 @@ use similar::TextDiff;
|
||||
use std::io::{Seek, SeekFrom};
|
||||
|
||||
mod agent_db;
|
||||
mod asset_export;
|
||||
mod asset_rename;
|
||||
mod checkpoint;
|
||||
mod conversation;
|
||||
@@ -18,6 +19,7 @@ mod resource_layout;
|
||||
mod verification;
|
||||
|
||||
pub(crate) use agent_db::*;
|
||||
pub(crate) use asset_export::*;
|
||||
pub(crate) use asset_rename::*;
|
||||
pub(crate) use checkpoint::*;
|
||||
pub(crate) use conversation::*;
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
/// 显式保存:把项目内已登记的素材文件复制到用户选定的目标路径。
|
||||
///
|
||||
/// 这里刻意不做"浏览器下载"——AGC 是 Tauri/WebView2 宿主,未注册 `on_download`
|
||||
/// 时 `<a download>` 能否落盘不可靠,所以保存路径由原生对话框给出,复制由 Rust 完成。
|
||||
const ASSET_EXPORT_COPY_CHUNK_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct SaveLocalProjectAssetFileInput {
|
||||
pub(crate) project_path: String,
|
||||
pub(crate) relative_path: String,
|
||||
pub(crate) destination_path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SaveLocalProjectAssetFileResult {
|
||||
pub(crate) destination_path: String,
|
||||
pub(crate) byte_len: u64,
|
||||
}
|
||||
|
||||
fn resolve_export_source_file(
|
||||
root: &Path,
|
||||
relative_path: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
let normalized = normalize_relative_path(relative_path.trim())?;
|
||||
if normalized.is_empty() {
|
||||
return Err("待保存的素材路径不能为空".to_string());
|
||||
}
|
||||
let source = root.join(&normalized);
|
||||
// 只允许保存项目根内真实存在的普通文件:符号链接与目录都要拒绝。
|
||||
let metadata = std::fs::symlink_metadata(&source)
|
||||
.map_err(|error| format!("素材文件不存在或不可读:{error}"))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err("素材路径不能是符号链接".to_string());
|
||||
}
|
||||
if !metadata.is_file() {
|
||||
return Err("素材路径必须是普通文件".to_string());
|
||||
}
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
fn resolve_export_destination_file(destination_path: &str) -> Result<PathBuf, String> {
|
||||
let trimmed = destination_path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("保存目标路径不能为空".to_string());
|
||||
}
|
||||
let destination = PathBuf::from(trimmed);
|
||||
if destination.file_name().is_none() {
|
||||
return Err("保存目标必须是文件路径".to_string());
|
||||
}
|
||||
let parent = destination
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.ok_or_else(|| "保存目标缺少父目录".to_string())?;
|
||||
if !parent.is_dir() {
|
||||
return Err("保存目标的父目录不存在".to_string());
|
||||
}
|
||||
if destination.is_dir() {
|
||||
return Err("保存目标不能是目录".to_string());
|
||||
}
|
||||
Ok(destination)
|
||||
}
|
||||
|
||||
pub(crate) fn save_local_project_asset_file_at(
|
||||
input: SaveLocalProjectAssetFileInput,
|
||||
) -> Result<SaveLocalProjectAssetFileResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
validate_project_root(root)?;
|
||||
enforce_project_auto_permission_policy(root, "file.read")?;
|
||||
|
||||
let source = resolve_export_source_file(root, &input.relative_path)?;
|
||||
let destination = resolve_export_destination_file(&input.destination_path)?;
|
||||
|
||||
let mut reader =
|
||||
File::open(&source).map_err(|error| format!("打开素材文件失败:{error}"))?;
|
||||
let mut writer = File::create(&destination)
|
||||
.map_err(|error| format!("创建保存目标失败:{error}"))?;
|
||||
let mut buffer = vec![0_u8; ASSET_EXPORT_COPY_CHUNK_BYTES];
|
||||
let mut byte_len = 0_u64;
|
||||
loop {
|
||||
let read = reader
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| format!("读取素材文件失败:{error}"))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
writer
|
||||
.write_all(&buffer[..read])
|
||||
.map_err(|error| format!("写入保存目标失败:{error}"))?;
|
||||
byte_len += read as u64;
|
||||
}
|
||||
writer
|
||||
.flush()
|
||||
.map_err(|error| format!("刷新保存目标失败:{error}"))?;
|
||||
|
||||
Ok(SaveLocalProjectAssetFileResult {
|
||||
destination_path: destination.to_string_lossy().into_owned(),
|
||||
byte_len,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT_ASSET_EXPORT_TEST_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn unique_asset_export_directory(prefix: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"genarrative-asset-export-{prefix}-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_ASSET_EXPORT_TEST_ID.fetch_add(1, Ordering::Relaxed)
|
||||
))
|
||||
}
|
||||
|
||||
fn create_export_project() -> (PathBuf, PathBuf) {
|
||||
let root = unique_asset_export_directory("project");
|
||||
let assets = root.join("assets");
|
||||
std::fs::create_dir_all(&assets).expect("create project assets directory");
|
||||
(root, assets)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_registered_asset_to_chosen_destination() {
|
||||
let (root, assets) = create_export_project();
|
||||
let source_bytes = b"genarrative-asset-export-payload".to_vec();
|
||||
std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset");
|
||||
let destination_directory = unique_asset_export_directory("destination");
|
||||
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
|
||||
let destination = destination_directory.join("hero-copy.png");
|
||||
|
||||
let result = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
relative_path: "assets/hero.png".to_string(),
|
||||
destination_path: destination.to_string_lossy().into_owned(),
|
||||
})
|
||||
.expect("save asset file");
|
||||
|
||||
assert_eq!(result.byte_len, source_bytes.len() as u64);
|
||||
assert_eq!(result.destination_path, destination.to_string_lossy());
|
||||
assert_eq!(
|
||||
std::fs::read(&destination).expect("read copied asset"),
|
||||
source_bytes
|
||||
);
|
||||
// 源素材保持不变:保存只是复制,不改动项目内文件。
|
||||
assert_eq!(
|
||||
std::fs::read(assets.join("hero.png")).expect("read source asset"),
|
||||
source_bytes
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&destination_directory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_source_asset() {
|
||||
let (root, _assets) = create_export_project();
|
||||
let destination_directory = unique_asset_export_directory("destination");
|
||||
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
|
||||
|
||||
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
relative_path: "assets/missing.png".to_string(),
|
||||
destination_path: destination_directory
|
||||
.join("missing-copy.png")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
})
|
||||
.expect_err("missing source must fail");
|
||||
|
||||
assert!(error.contains("素材文件不存在或不可读"), "{error}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&destination_directory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_directory_as_source_asset() {
|
||||
let (root, assets) = create_export_project();
|
||||
std::fs::create_dir_all(assets.join("nested")).expect("create nested directory");
|
||||
let destination_directory = unique_asset_export_directory("destination");
|
||||
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
|
||||
|
||||
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
relative_path: "assets/nested".to_string(),
|
||||
destination_path: destination_directory
|
||||
.join("nested-copy")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
})
|
||||
.expect_err("directory source must fail");
|
||||
|
||||
assert!(error.contains("必须是普通文件"), "{error}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&destination_directory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_blank_destination_and_missing_parent_directory() {
|
||||
let (root, assets) = create_export_project();
|
||||
std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset");
|
||||
|
||||
let blank = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
relative_path: "assets/hero.png".to_string(),
|
||||
destination_path: " ".to_string(),
|
||||
})
|
||||
.expect_err("blank destination must fail");
|
||||
assert!(blank.contains("保存目标路径不能为空"), "{blank}");
|
||||
|
||||
let missing_parent_directory = unique_asset_export_directory("absent");
|
||||
let missing_parent = save_local_project_asset_file_at(
|
||||
SaveLocalProjectAssetFileInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
relative_path: "assets/hero.png".to_string(),
|
||||
destination_path: missing_parent_directory
|
||||
.join("hero-copy.png")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
},
|
||||
)
|
||||
.expect_err("missing parent directory must fail");
|
||||
assert!(
|
||||
missing_parent.contains("保存目标的父目录不存在"),
|
||||
"{missing_parent}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_destination_that_is_an_existing_directory() {
|
||||
let (root, assets) = create_export_project();
|
||||
std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset");
|
||||
let destination_directory = unique_asset_export_directory("destination");
|
||||
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
|
||||
|
||||
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
relative_path: "assets/hero.png".to_string(),
|
||||
destination_path: destination_directory.to_string_lossy().into_owned(),
|
||||
})
|
||||
.expect_err("directory destination must fail");
|
||||
|
||||
assert!(error.contains("保存目标不能是目录"), "{error}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&destination_directory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Download, Loader2, Upload, X } from 'lucide-react';
|
||||
import { useEffect, useId, useRef } from 'react';
|
||||
|
||||
import type { ResourceCanvasPanelEntry } from './resourceCanvasAssetTransferModel';
|
||||
|
||||
export type ResourceCanvasPanelViewProps = {
|
||||
entries: readonly ResourceCanvasPanelEntry[];
|
||||
selectedResourceIds: readonly string[];
|
||||
onToggleEntry: (resourceId: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onClearSelection: () => void;
|
||||
onUploadFiles: (files: FileList) => void;
|
||||
onDownloadSelection: () => void;
|
||||
isUploading: boolean;
|
||||
notice: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 资源面板:独立的浮层面板(不挂在其它面板下面),承载预览 / 上传 / 下载 / 多选。
|
||||
*
|
||||
* 选中状态直接读写画布那一份 `selectedResourceIds`,面板不维护第二套选择状态。
|
||||
*/
|
||||
export function ResourceCanvasPanelView({
|
||||
entries,
|
||||
selectedResourceIds,
|
||||
onToggleEntry,
|
||||
onSelectAll,
|
||||
onClearSelection,
|
||||
onUploadFiles,
|
||||
onDownloadSelection,
|
||||
isUploading,
|
||||
notice,
|
||||
onClose,
|
||||
}: ResourceCanvasPanelViewProps) {
|
||||
const titleId = useId();
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const selected = new Set(selectedResourceIds);
|
||||
const selectedCount = entries.filter((entry) =>
|
||||
selected.has(entry.resourceId),
|
||||
).length;
|
||||
|
||||
useEffect(() => {
|
||||
closeButtonRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="game-approval-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
className="game-approval-dialog game-resource-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h2 id={titleId}>资源面板</h2>
|
||||
<p role="status">{`${entries.length} 项资源,已选 ${selectedCount} 项`}</p>
|
||||
</div>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
aria-label="关闭资源面板"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="game-resource-panel-actions">
|
||||
<label className="game-resource-panel-upload">
|
||||
<Upload size={15} aria-hidden="true" />
|
||||
{isUploading ? '上传中…' : '上传素材'}
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,audio/*,video/*"
|
||||
disabled={isUploading}
|
||||
aria-label="选择要上传到项目的素材"
|
||||
onChange={(event) => {
|
||||
const files = event.currentTarget.files;
|
||||
if (files && files.length > 0) {
|
||||
onUploadFiles(files);
|
||||
}
|
||||
event.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectAll}
|
||||
disabled={entries.length === 0 || selectedCount === entries.length}
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSelection}
|
||||
disabled={selectedCount === 0}
|
||||
>
|
||||
清空选择
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="game-resource-panel-download"
|
||||
onClick={onDownloadSelection}
|
||||
disabled={selectedCount === 0 || isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Download size={15} aria-hidden="true" />
|
||||
)}
|
||||
{selectedCount > 1 ? `下载选中(${selectedCount})` : '下载'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notice ? (
|
||||
<p className="game-resource-panel-notice" role="status">
|
||||
{notice}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="game-resource-panel-empty">当前筛选下没有资源</p>
|
||||
) : (
|
||||
<ul className="game-resource-panel-grid">
|
||||
{entries.map((entry) => {
|
||||
const isSelected = selected.has(entry.resourceId);
|
||||
return (
|
||||
<li
|
||||
key={entry.resourceId}
|
||||
className={`game-resource-panel-card${isSelected ? ' is-selected' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="game-resource-panel-preview"
|
||||
aria-label={`${isSelected ? '取消选择' : '选择'} ${entry.label}`}
|
||||
aria-pressed={isSelected}
|
||||
disabled={!entry.selectable}
|
||||
onClick={() => onToggleEntry(entry.resourceId)}
|
||||
>
|
||||
{entry.previewStatus === 'loaded' &&
|
||||
entry.previewSourceUrl ? (
|
||||
<img src={entry.previewSourceUrl} alt="" />
|
||||
) : (
|
||||
<span className="game-resource-panel-preview-placeholder">
|
||||
{entry.previewStatus === 'failed'
|
||||
? '预览失败'
|
||||
: entry.previewStatus === 'loading'
|
||||
? '载入中…'
|
||||
: entry.typeLabel}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="game-resource-panel-meta">
|
||||
<strong title={entry.label}>{entry.label}</strong>
|
||||
<small>{`${entry.categoryLabel} · ${entry.typeLabel}`}</small>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import type { ProjectResource } from '../../view/project-development/resourceProjectionModel';
|
||||
|
||||
/** 上传只接受浏览器/客户端都能直读字节的图片与音频,其它类型走 Agent 侧登记。 */
|
||||
const UPLOADABLE_MEDIA_TYPE_PREFIXES = ['image/', 'audio/', 'video/'];
|
||||
|
||||
export type ResourceCanvasPanelEntry = {
|
||||
resourceId: string;
|
||||
label: string;
|
||||
categoryLabel: string;
|
||||
typeLabel: string;
|
||||
path: string;
|
||||
mediaType: string;
|
||||
selectable: boolean;
|
||||
downloadable: boolean;
|
||||
previewIdentity: string | null;
|
||||
previewSourceUrl: string | null;
|
||||
previewStatus: 'idle' | 'loading' | 'loaded' | 'failed';
|
||||
previewError: string | null;
|
||||
};
|
||||
|
||||
export type ResourceCanvasPanelEntryInput = {
|
||||
resource: ProjectResource;
|
||||
categoryLabel: string;
|
||||
typeLabel: string;
|
||||
previewIdentity: string | null;
|
||||
previewStatus: 'idle' | 'loading' | 'loaded' | 'failed';
|
||||
previewSourceUrl: string | null;
|
||||
previewError: string | null;
|
||||
};
|
||||
|
||||
export function resolveResourceCanvasPanelEntries(
|
||||
inputs: readonly ResourceCanvasPanelEntryInput[],
|
||||
): ResourceCanvasPanelEntry[] {
|
||||
return inputs.map((input) => ({
|
||||
resourceId: input.resource.id,
|
||||
label: input.resource.label,
|
||||
categoryLabel: input.categoryLabel,
|
||||
typeLabel: input.typeLabel,
|
||||
path: input.resource.path,
|
||||
mediaType: input.resource.mediaType,
|
||||
selectable: true,
|
||||
// 保存走本地文件复制,因此只要求有可读路径;虚拟版本条目没有文件,不能保存。
|
||||
downloadable:
|
||||
input.resource.version === undefined && input.resource.path !== '',
|
||||
previewIdentity: input.previewIdentity,
|
||||
previewSourceUrl: input.previewSourceUrl,
|
||||
previewStatus: input.previewStatus,
|
||||
previewError: input.previewError,
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveSelectedPanelEntries(
|
||||
entries: readonly ResourceCanvasPanelEntry[],
|
||||
selectedResourceIds: readonly string[],
|
||||
) {
|
||||
const selected = new Set(selectedResourceIds);
|
||||
return entries.filter((entry) => selected.has(entry.resourceId));
|
||||
}
|
||||
|
||||
export function resolveDownloadablePanelEntries(
|
||||
entries: readonly ResourceCanvasPanelEntry[],
|
||||
) {
|
||||
return entries.filter((entry) => entry.downloadable);
|
||||
}
|
||||
|
||||
export function resourceFileNameFromPath(path: string) {
|
||||
const segments = path.split(/[\\/]/u).filter(Boolean);
|
||||
return segments.at(-1) ?? path;
|
||||
}
|
||||
|
||||
export function defaultResourceExportFileName(
|
||||
entry: Pick<ResourceCanvasPanelEntry, 'label' | 'path'>,
|
||||
) {
|
||||
const fromPath = resourceFileNameFromPath(entry.path);
|
||||
if (fromPath && fromPath !== entry.path) {
|
||||
return fromPath;
|
||||
}
|
||||
return entry.label;
|
||||
}
|
||||
|
||||
export function isUploadableResourceFile(file: {
|
||||
type?: string;
|
||||
name: string;
|
||||
}) {
|
||||
const mediaType = (file.type ?? '').toLowerCase();
|
||||
if (
|
||||
UPLOADABLE_MEDIA_TYPE_PREFIXES.some((prefix) =>
|
||||
mediaType.startsWith(prefix),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return /\.(png|jpe?g|webp|gif|bmp|avif|mp3|wav|ogg|m4a|mp4|webm|mov)$/iu.test(
|
||||
file.name,
|
||||
);
|
||||
}
|
||||
@@ -239,3 +239,138 @@
|
||||
width: 94vw;
|
||||
}
|
||||
}
|
||||
|
||||
/* 资源面板:独立浮层面板(预览 / 上传 / 下载 / 多选)。 */
|
||||
.game-resource-panel {
|
||||
width: min(880px, calc(100% - 32px));
|
||||
max-height: calc(100% - 32px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.game-resource-panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.game-resource-panel-actions > button,
|
||||
.game-resource-panel-upload {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
height: 32px;
|
||||
padding: 0 0.7rem;
|
||||
border: 1px solid #ecdcd4;
|
||||
border-radius: 8px;
|
||||
background: #fffdfa;
|
||||
color: #6b4a3c;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-resource-panel-actions > button:disabled,
|
||||
.game-resource-panel-upload:has(input:disabled) {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.game-resource-panel-upload {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-resource-panel-upload input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-resource-panel-download {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.game-resource-panel-notice {
|
||||
margin: 0;
|
||||
color: #8c6252;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.game-resource-panel-empty {
|
||||
margin: 0;
|
||||
color: #8c6252;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.game-resource-panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 0.6rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.game-resource-panel-card {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0.4rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: #fffaf6;
|
||||
}
|
||||
|
||||
.game-resource-panel-card.is-selected {
|
||||
border-color: #d57b51;
|
||||
box-shadow: 0 0 0 2px rgb(216 115 66 / 16%);
|
||||
}
|
||||
|
||||
.game-resource-panel-preview {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(145deg, #fffaf6, #f6ece6);
|
||||
color: #c46a40;
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.game-resource-panel-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.game-resource-panel-preview-placeholder {
|
||||
font-size: 0.72rem;
|
||||
color: #a97f6c;
|
||||
}
|
||||
|
||||
.game-resource-panel-meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.game-resource-panel-meta strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.76rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-resource-panel-meta small {
|
||||
color: #8c6252;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 资源画布的组织操作历史:只记录资源卡的布局坐标,**不记录、也不回滚素材内容**。
|
||||
*
|
||||
* 素材不可变是硬约束,所以撤销/重做只作用于「画布上卡片怎么排」,绝不反向修改
|
||||
* manifest 条目或素材文件。持久化仍走现役的手动 CAS 写链(`commitPosition`),
|
||||
* 本模块只做纯函数的快照栈。
|
||||
*/
|
||||
|
||||
import type { ProjectResourceCanvasSection } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
|
||||
export const MAX_RESOURCE_CANVAS_HISTORY_STEPS = 40;
|
||||
|
||||
export type ResourceCanvasLayoutSnapshotEntry = {
|
||||
resourceId: string;
|
||||
section: ProjectResourceCanvasSection;
|
||||
x: number;
|
||||
y: number;
|
||||
manuallyPlaced: boolean;
|
||||
};
|
||||
|
||||
export type ResourceCanvasLayoutSnapshot = {
|
||||
entries: ResourceCanvasLayoutSnapshotEntry[];
|
||||
};
|
||||
|
||||
export type ResourceCanvasHistoryEntry = {
|
||||
id: number;
|
||||
label: string;
|
||||
snapshot: ResourceCanvasLayoutSnapshot;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type ResourceCanvasHistory = {
|
||||
undoStack: ResourceCanvasHistoryEntry[];
|
||||
redoStack: ResourceCanvasHistoryEntry[];
|
||||
nextEntryId: number;
|
||||
};
|
||||
|
||||
export function createResourceCanvasHistory(): ResourceCanvasHistory {
|
||||
return { undoStack: [], redoStack: [], nextEntryId: 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 resourceId 排序后落快照,保证同一组坐标无论 map 顺序如何都比较得出相等。
|
||||
*/
|
||||
export function captureResourceCanvasSnapshot(
|
||||
positions: readonly ResourceCanvasLayoutSnapshotEntry[],
|
||||
): ResourceCanvasLayoutSnapshot {
|
||||
return {
|
||||
entries: positions
|
||||
.map((position) => ({
|
||||
resourceId: position.resourceId,
|
||||
section: position.section,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
manuallyPlaced: position.manuallyPlaced,
|
||||
}))
|
||||
.sort((left, right) => left.resourceId.localeCompare(right.resourceId)),
|
||||
};
|
||||
}
|
||||
|
||||
export function resourceCanvasSnapshotsEqual(
|
||||
left: ResourceCanvasLayoutSnapshot,
|
||||
right: ResourceCanvasLayoutSnapshot,
|
||||
) {
|
||||
if (left.entries.length !== right.entries.length) {
|
||||
return false;
|
||||
}
|
||||
return left.entries.every((entry, index) => {
|
||||
const other = right.entries[index];
|
||||
return (
|
||||
other !== undefined &&
|
||||
entry.resourceId === other.resourceId &&
|
||||
entry.section === other.section &&
|
||||
entry.x === other.x &&
|
||||
entry.y === other.y &&
|
||||
entry.manuallyPlaced === other.manuallyPlaced
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function pushResourceCanvasHistory(
|
||||
history: ResourceCanvasHistory,
|
||||
{
|
||||
label,
|
||||
snapshot,
|
||||
createdAt = 0,
|
||||
}: {
|
||||
label: string;
|
||||
snapshot: ResourceCanvasLayoutSnapshot;
|
||||
createdAt?: number;
|
||||
},
|
||||
): ResourceCanvasHistory {
|
||||
const previous = history.undoStack.at(-1);
|
||||
if (previous && resourceCanvasSnapshotsEqual(previous.snapshot, snapshot)) {
|
||||
return history;
|
||||
}
|
||||
const entry: ResourceCanvasHistoryEntry = {
|
||||
id: history.nextEntryId,
|
||||
label,
|
||||
snapshot,
|
||||
createdAt,
|
||||
};
|
||||
return {
|
||||
undoStack: [
|
||||
...history.undoStack.slice(-(MAX_RESOURCE_CANVAS_HISTORY_STEPS - 1)),
|
||||
entry,
|
||||
],
|
||||
// 新的操作会让原来的重做分支失效。
|
||||
redoStack: [],
|
||||
nextEntryId: history.nextEntryId + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export type ResourceCanvasHistoryStep = {
|
||||
history: ResourceCanvasHistory;
|
||||
label: string;
|
||||
snapshot: ResourceCanvasLayoutSnapshot;
|
||||
};
|
||||
|
||||
export function undoResourceCanvasHistory(
|
||||
history: ResourceCanvasHistory,
|
||||
currentSnapshot: ResourceCanvasLayoutSnapshot,
|
||||
): ResourceCanvasHistoryStep | null {
|
||||
const entry = history.undoStack.at(-1);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
history: {
|
||||
undoStack: history.undoStack.slice(0, -1),
|
||||
redoStack: [
|
||||
...history.redoStack.slice(-(MAX_RESOURCE_CANVAS_HISTORY_STEPS - 1)),
|
||||
{
|
||||
...entry,
|
||||
snapshot: currentSnapshot,
|
||||
},
|
||||
],
|
||||
nextEntryId: history.nextEntryId,
|
||||
},
|
||||
label: entry.label,
|
||||
snapshot: entry.snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export function redoResourceCanvasHistory(
|
||||
history: ResourceCanvasHistory,
|
||||
currentSnapshot: ResourceCanvasLayoutSnapshot,
|
||||
): ResourceCanvasHistoryStep | null {
|
||||
const entry = history.redoStack.at(-1);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
history: {
|
||||
undoStack: [
|
||||
...history.undoStack.slice(-(MAX_RESOURCE_CANVAS_HISTORY_STEPS - 1)),
|
||||
{
|
||||
...entry,
|
||||
snapshot: currentSnapshot,
|
||||
},
|
||||
],
|
||||
redoStack: history.redoStack.slice(0, -1),
|
||||
nextEntryId: history.nextEntryId,
|
||||
},
|
||||
label: entry.label,
|
||||
snapshot: entry.snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearResourceCanvasHistory(
|
||||
history: ResourceCanvasHistory,
|
||||
): ResourceCanvasHistory {
|
||||
if (history.undoStack.length === 0 && history.redoStack.length === 0) {
|
||||
return history;
|
||||
}
|
||||
return { ...history, undoStack: [], redoStack: [] };
|
||||
}
|
||||
|
||||
export function canUndoResourceCanvasHistory(history: ResourceCanvasHistory) {
|
||||
return history.undoStack.length > 0;
|
||||
}
|
||||
|
||||
export function canRedoResourceCanvasHistory(history: ResourceCanvasHistory) {
|
||||
return history.redoStack.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只回写和快照不一致的位置,避免撤销时把没动过的卡片也当成一次手动摆放写回后端。
|
||||
*/
|
||||
export function resolveResourceCanvasRestoreEntries(
|
||||
snapshot: ResourceCanvasLayoutSnapshot,
|
||||
current: readonly ResourceCanvasLayoutSnapshotEntry[],
|
||||
): ResourceCanvasLayoutSnapshotEntry[] {
|
||||
const currentById = new Map(
|
||||
current.map((entry) => [entry.resourceId, entry] as const),
|
||||
);
|
||||
return snapshot.entries.filter((entry) => {
|
||||
const existing = currentById.get(entry.resourceId);
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
existing.section !== entry.section ||
|
||||
existing.x !== entry.x ||
|
||||
existing.y !== entry.y
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CanvasChromeButton,
|
||||
SelectionOverlay,
|
||||
} from '@genarrative/image-canvas-react';
|
||||
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
|
||||
import {
|
||||
AtSign,
|
||||
Code2,
|
||||
@@ -30,11 +31,13 @@ import {
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
Redo2,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Undo2,
|
||||
X,
|
||||
ZoomOut,
|
||||
} from 'lucide-react';
|
||||
@@ -86,6 +89,25 @@ import {
|
||||
type ResourceReferenceFilter,
|
||||
} from '../../features/project-workspace/resourceReferences';
|
||||
import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker';
|
||||
import {
|
||||
defaultResourceExportFileName,
|
||||
isUploadableResourceFile,
|
||||
resolveResourceCanvasPanelEntries,
|
||||
resolveSelectedPanelEntries,
|
||||
} from '../../features/resource-canvas/resourceCanvasAssetTransferModel';
|
||||
import {
|
||||
canRedoResourceCanvasHistory,
|
||||
canUndoResourceCanvasHistory,
|
||||
captureResourceCanvasSnapshot,
|
||||
clearResourceCanvasHistory,
|
||||
createResourceCanvasHistory,
|
||||
pushResourceCanvasHistory,
|
||||
redoResourceCanvasHistory,
|
||||
resolveResourceCanvasRestoreEntries,
|
||||
type ResourceCanvasLayoutSnapshot,
|
||||
undoResourceCanvasHistory,
|
||||
} from '../../features/resource-canvas/resourceCanvasHistoryModel';
|
||||
import { ResourceCanvasPanelView } from '../../features/resource-canvas/ResourceCanvasPanelView';
|
||||
import { createResourceQuickEditPanelDraft } from '../../features/resource-canvas/resourceCanvasQuickEditModel';
|
||||
import {
|
||||
canNormalizeResourceIntoManifestAsset,
|
||||
@@ -1220,6 +1242,13 @@ export default function ProjectDevelopmentView({
|
||||
useState<QuickEditPanelState | null>(null);
|
||||
const [resourceCanvasMarquee, setResourceCanvasMarquee] =
|
||||
useState<CanvasMarqueeState | null>(null);
|
||||
/** 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 */
|
||||
const [resourceCanvasHistory, setResourceCanvasHistory] = useState(
|
||||
createResourceCanvasHistory,
|
||||
);
|
||||
const [resourcePanelOpen, setResourcePanelOpen] = useState(false);
|
||||
const [resourcePanelNotice, setResourcePanelNotice] = useState('');
|
||||
const [resourcePanelUploading, setResourcePanelUploading] = useState(false);
|
||||
const [uiEditorRoute, setUiEditorRoute] = useState<UiEditorRoute | null>(
|
||||
null,
|
||||
);
|
||||
@@ -3074,6 +3103,7 @@ export default function ProjectDevelopmentView({
|
||||
setSelectedResourceIds([]);
|
||||
setQuickEditPanel(null);
|
||||
setQuickEditSourceLayer(null);
|
||||
setResourceCanvasHistory(clearResourceCanvasHistory);
|
||||
setActiveResourceCategory(null);
|
||||
const defaultViewports = defaultResourceCanvasViewports();
|
||||
resourceCanvasViewportTargetsRef.current = defaultViewports;
|
||||
@@ -3354,6 +3384,246 @@ export default function ProjectDevelopmentView({
|
||||
}
|
||||
}, [resourceCardPreviews.previews, stopActiveCardMedia]);
|
||||
|
||||
const resourcePanelEntries = useMemo(
|
||||
() =>
|
||||
resolveResourceCanvasPanelEntries(
|
||||
visibleResources.map((resource) => {
|
||||
const previewIdentity =
|
||||
resourceCardPreviews.identityByResourceId.get(resource.id) ?? null;
|
||||
const preview = previewIdentity
|
||||
? resourceCardPreviews.previews.get(previewIdentity)
|
||||
: undefined;
|
||||
return {
|
||||
resource,
|
||||
categoryLabel: categoryLabels[resource.category],
|
||||
typeLabel: projectResourceTypeLabel(resource),
|
||||
previewIdentity,
|
||||
previewStatus: preview?.status ?? 'idle',
|
||||
previewSourceUrl:
|
||||
preview?.status === 'loaded'
|
||||
? (preview.preview.sourceUrl ?? null)
|
||||
: null,
|
||||
previewError:
|
||||
preview?.status === 'failed' ? (preview.error ?? null) : null,
|
||||
};
|
||||
}),
|
||||
),
|
||||
[resourceCardPreviews, visibleResources],
|
||||
);
|
||||
const selectedResourcePanelEntries = resolveSelectedPanelEntries(
|
||||
resourcePanelEntries,
|
||||
selectedResourceIds,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resourcePanelOpen) {
|
||||
return;
|
||||
}
|
||||
// 面板打开时补一次预览请求;卡片自身只按可见性请求。
|
||||
resourcePanelEntries.slice(0, 24).forEach((entry) => {
|
||||
const resource = canvasResources.find(
|
||||
(item) => item.id === entry.resourceId,
|
||||
);
|
||||
if (resource && entry.previewIdentity) {
|
||||
resourceCardPreviews.requestPreview(
|
||||
resource,
|
||||
entry.previewIdentity,
|
||||
'detail',
|
||||
);
|
||||
}
|
||||
});
|
||||
}, [
|
||||
canvasResources,
|
||||
resourceCardPreviews,
|
||||
resourcePanelEntries,
|
||||
resourcePanelOpen,
|
||||
]);
|
||||
|
||||
const downloadResourcePanelEntries = useCallback(
|
||||
async (targets: typeof resourcePanelEntries) => {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke || targets.length === 0) {
|
||||
if (!invoke) {
|
||||
setResourcePanelNotice('保存素材需要在客户端内打开');
|
||||
}
|
||||
return;
|
||||
}
|
||||
setResourcePanelNotice('');
|
||||
let savedCount = 0;
|
||||
for (const [index, entry] of targets.entries()) {
|
||||
const destination = await saveNativeFileDialog({
|
||||
defaultPath: defaultResourceExportFileName(entry),
|
||||
title:
|
||||
targets.length > 1
|
||||
? `保存素材(${index + 1}/${targets.length})`
|
||||
: '保存素材',
|
||||
});
|
||||
if (!destination) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const result = await invoke<{
|
||||
destinationPath: string;
|
||||
byteLen: number;
|
||||
}>('save_local_project_asset_file', {
|
||||
input: {
|
||||
projectPath,
|
||||
relativePath: entry.path,
|
||||
destinationPath: destination,
|
||||
},
|
||||
});
|
||||
savedCount += 1;
|
||||
setResourcePanelNotice(`已保存 ${result.destinationPath}`);
|
||||
} catch (error) {
|
||||
setResourcePanelNotice(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (savedCount > 1) {
|
||||
setResourcePanelNotice(`已保存 ${savedCount} 个素材`);
|
||||
}
|
||||
},
|
||||
[projectPath],
|
||||
);
|
||||
|
||||
const uploadResourcePanelFiles = useCallback(
|
||||
async (files: FileList) => {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
setResourcePanelNotice('上传素材需要在客户端内打开');
|
||||
return;
|
||||
}
|
||||
const uploadable = Array.from(files).filter(isUploadableResourceFile);
|
||||
if (uploadable.length === 0) {
|
||||
setResourcePanelNotice('只能上传图片、音频或视频素材');
|
||||
return;
|
||||
}
|
||||
setResourcePanelUploading(true);
|
||||
setResourcePanelNotice('');
|
||||
try {
|
||||
let expectedProjectRevision: number | null = null;
|
||||
for (const file of uploadable) {
|
||||
const status = await invoke<{ revision: number }>(
|
||||
'get_local_game_project_revision',
|
||||
{ projectPath },
|
||||
);
|
||||
if (!Number.isSafeInteger(status.revision) || status.revision < 0) {
|
||||
throw new Error('项目 revision 无效');
|
||||
}
|
||||
expectedProjectRevision = status.revision;
|
||||
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
|
||||
await invoke('upload_local_asset', {
|
||||
projectPath,
|
||||
fileName: file.name,
|
||||
mediaType: file.type || 'application/octet-stream',
|
||||
bytes,
|
||||
});
|
||||
}
|
||||
setResourcePanelNotice(`已上传 ${uploadable.length} 个素材`);
|
||||
if (expectedProjectRevision !== null) {
|
||||
await reloadManifestAfterAssetCommand(
|
||||
expectedProjectRevision,
|
||||
`asset-upload:${Date.now()}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setResourcePanelNotice(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} finally {
|
||||
setResourcePanelUploading(false);
|
||||
}
|
||||
},
|
||||
[projectPath, reloadManifestAfterAssetCommand],
|
||||
);
|
||||
|
||||
const applyResourceCanvasLayoutSnapshot = useCallback(
|
||||
(snapshot: ResourceCanvasLayoutSnapshot) => {
|
||||
resolveResourceCanvasRestoreEntries(
|
||||
snapshot,
|
||||
activeResourceLayout.layout.positions,
|
||||
).forEach((entry) => {
|
||||
activeResourceLayout.commitPosition(
|
||||
entry.resourceId,
|
||||
entry.section,
|
||||
entry.x,
|
||||
entry.y,
|
||||
);
|
||||
});
|
||||
},
|
||||
[activeResourceLayout],
|
||||
);
|
||||
|
||||
const undoSaveResourceCanvasLayout = useCallback(() => {
|
||||
const step = undoResourceCanvasHistory(
|
||||
resourceCanvasHistory,
|
||||
captureResourceCanvasSnapshot(activeResourceLayout.layout.positions),
|
||||
);
|
||||
if (!step) {
|
||||
return;
|
||||
}
|
||||
setResourceCanvasHistory(step.history);
|
||||
applyResourceCanvasLayoutSnapshot(step.snapshot);
|
||||
}, [
|
||||
activeResourceLayout.layout.positions,
|
||||
applyResourceCanvasLayoutSnapshot,
|
||||
resourceCanvasHistory,
|
||||
]);
|
||||
|
||||
const redoResourceCanvasLayout = useCallback(() => {
|
||||
const step = redoResourceCanvasHistory(
|
||||
resourceCanvasHistory,
|
||||
captureResourceCanvasSnapshot(activeResourceLayout.layout.positions),
|
||||
);
|
||||
if (!step) {
|
||||
return;
|
||||
}
|
||||
setResourceCanvasHistory(step.history);
|
||||
applyResourceCanvasLayoutSnapshot(step.snapshot);
|
||||
}, [
|
||||
activeResourceLayout.layout.positions,
|
||||
applyResourceCanvasLayoutSnapshot,
|
||||
resourceCanvasHistory,
|
||||
]);
|
||||
|
||||
const canUndoResourceCanvas = canUndoResourceCanvasHistory(
|
||||
resourceCanvasHistory,
|
||||
);
|
||||
const canRedoResourceCanvas = canRedoResourceCanvasHistory(
|
||||
resourceCanvasHistory,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHistoryShortcut = (event: KeyboardEvent) => {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.altKey) {
|
||||
return;
|
||||
}
|
||||
const key = event.key.toLowerCase();
|
||||
const isUndo = key === 'z' && !event.shiftKey;
|
||||
const isRedo = (key === 'z' && event.shiftKey) || key === 'y';
|
||||
if (!isUndo && !isRedo) {
|
||||
return;
|
||||
}
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
target.closest('input, textarea, [contenteditable="true"]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (isUndo) {
|
||||
undoSaveResourceCanvasLayout();
|
||||
} else {
|
||||
redoResourceCanvasLayout();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleHistoryShortcut);
|
||||
return () => window.removeEventListener('keydown', handleHistoryShortcut);
|
||||
}, [redoResourceCanvasLayout, undoSaveResourceCanvasLayout]);
|
||||
|
||||
const handleResourceSelect = useCallback(
|
||||
(resourceId: string, options: { append?: boolean } = {}) => {
|
||||
if (skipNextResourceCardClickRef.current === resourceId) {
|
||||
@@ -3699,6 +3969,15 @@ export default function ProjectDevelopmentView({
|
||||
: null;
|
||||
setResourceCardDragPreview(null);
|
||||
if (commit && drag.changed && preview?.resourceId === drag.resourceId) {
|
||||
// 先落"拖动前"的快照,撤销才有回退点;它只含布局坐标。
|
||||
setResourceCanvasHistory((history) =>
|
||||
pushResourceCanvasHistory(history, {
|
||||
label: '移动资源卡',
|
||||
snapshot: captureResourceCanvasSnapshot(
|
||||
activeResourceLayout.layout.positions,
|
||||
),
|
||||
}),
|
||||
);
|
||||
activeResourceLayout.commitPosition(
|
||||
drag.resourceId,
|
||||
drag.section,
|
||||
@@ -4937,6 +5216,19 @@ export default function ProjectDevelopmentView({
|
||||
) : null}
|
||||
{mode === 'resources' && !uiEditorRoute ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="game-workbench-resource-panel-button"
|
||||
aria-expanded={resourcePanelOpen}
|
||||
disabled={canvasResources.length === 0}
|
||||
onClick={() => {
|
||||
setResourcePanelNotice('');
|
||||
setResourcePanelOpen((current) => !current);
|
||||
}}
|
||||
>
|
||||
<FolderTree size={15} aria-hidden="true" />
|
||||
资源面板
|
||||
</button>
|
||||
{pendingResourceEdits.length > 0 ||
|
||||
pendingResourceEditsLoadState === 'failed' ? (
|
||||
<button
|
||||
@@ -5150,6 +5442,26 @@ export default function ProjectDevelopmentView({
|
||||
onWheel={handleResourceBookWheel}
|
||||
/>
|
||||
<div className="game-resource-book-zoom">
|
||||
<button
|
||||
type="button"
|
||||
className="game-resource-book-zoom-button"
|
||||
aria-label="撤销画布操作"
|
||||
title="撤销(Ctrl/Cmd+Z)"
|
||||
disabled={!canUndoResourceCanvas}
|
||||
onClick={undoSaveResourceCanvasLayout}
|
||||
>
|
||||
<Undo2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="game-resource-book-zoom-button"
|
||||
aria-label="重做画布操作"
|
||||
title="重做(Ctrl/Cmd+Shift+Z)"
|
||||
disabled={!canRedoResourceCanvas}
|
||||
onClick={redoResourceCanvasLayout}
|
||||
>
|
||||
<Redo2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="game-resource-book-zoom-button"
|
||||
@@ -5653,6 +5965,32 @@ export default function ProjectDevelopmentView({
|
||||
</footer>
|
||||
) : null}
|
||||
|
||||
{resourcePanelOpen ? (
|
||||
<ResourceCanvasPanelView
|
||||
entries={resourcePanelEntries}
|
||||
selectedResourceIds={selectedResourceIds}
|
||||
onToggleEntry={(resourceId) =>
|
||||
handleResourceSelect(resourceId, { append: true })
|
||||
}
|
||||
onSelectAll={() =>
|
||||
setSelectedResourceIds(
|
||||
resourcePanelEntries.map((entry) => entry.resourceId),
|
||||
)
|
||||
}
|
||||
onClearSelection={() => setSelectedResourceIds([])}
|
||||
onUploadFiles={(files) => void uploadResourcePanelFiles(files)}
|
||||
onDownloadSelection={() =>
|
||||
void downloadResourcePanelEntries(selectedResourcePanelEntries)
|
||||
}
|
||||
isUploading={resourcePanelUploading}
|
||||
notice={resourcePanelNotice}
|
||||
onClose={() => {
|
||||
setResourcePanelOpen(false);
|
||||
setResourcePanelNotice('');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{resourceClassificationAsset ? (
|
||||
<ResourceClassificationPanel
|
||||
key={resourceClassificationAsset.id}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
canRedoResourceCanvasHistory,
|
||||
canUndoResourceCanvasHistory,
|
||||
captureResourceCanvasSnapshot,
|
||||
clearResourceCanvasHistory,
|
||||
createResourceCanvasHistory,
|
||||
MAX_RESOURCE_CANVAS_HISTORY_STEPS,
|
||||
pushResourceCanvasHistory,
|
||||
redoResourceCanvasHistory,
|
||||
resolveResourceCanvasRestoreEntries,
|
||||
undoResourceCanvasHistory,
|
||||
} from '../src/features/resource-canvas/resourceCanvasHistoryModel';
|
||||
|
||||
function positions(
|
||||
entries: Array<[string, number, number, boolean?]>,
|
||||
): ReturnType<typeof captureResourceCanvasSnapshot>['entries'] {
|
||||
return entries.map(([resourceId, x, y, manuallyPlaced = true]) => ({
|
||||
resourceId,
|
||||
section: 'document',
|
||||
x,
|
||||
y,
|
||||
manuallyPlaced,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('resource canvas history model', () => {
|
||||
it('按 resourceId 排序落快照,map 顺序不影响相等判定', () => {
|
||||
const left = captureResourceCanvasSnapshot(
|
||||
positions([
|
||||
['asset:b', 2, 2],
|
||||
['asset:a', 1, 1],
|
||||
]),
|
||||
);
|
||||
const right = captureResourceCanvasSnapshot(
|
||||
positions([
|
||||
['asset:a', 1, 1],
|
||||
['asset:b', 2, 2],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(left.entries.map((entry) => entry.resourceId)).toEqual([
|
||||
'asset:a',
|
||||
'asset:b',
|
||||
]);
|
||||
expect(left).toEqual(right);
|
||||
});
|
||||
|
||||
it('push 连续相同快照不产生新历史条目', () => {
|
||||
const snapshot = captureResourceCanvasSnapshot(
|
||||
positions([['asset:a', 1, 1]]),
|
||||
);
|
||||
const first = pushResourceCanvasHistory(createResourceCanvasHistory(), {
|
||||
label: '移动卡片',
|
||||
snapshot,
|
||||
});
|
||||
const second = pushResourceCanvasHistory(first, {
|
||||
label: '移动卡片',
|
||||
snapshot: captureResourceCanvasSnapshot(positions([['asset:a', 1, 1]])),
|
||||
});
|
||||
|
||||
expect(first.undoStack).toHaveLength(1);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it('push 后重做分支失效,撤回栈有界', () => {
|
||||
let history = createResourceCanvasHistory();
|
||||
for (
|
||||
let index = 0;
|
||||
index < MAX_RESOURCE_CANVAS_HISTORY_STEPS + 5;
|
||||
index += 1
|
||||
) {
|
||||
history = pushResourceCanvasHistory(history, {
|
||||
label: `移动 ${index}`,
|
||||
snapshot: captureResourceCanvasSnapshot(
|
||||
positions([['asset:a', index, index]]),
|
||||
),
|
||||
});
|
||||
}
|
||||
expect(history.undoStack).toHaveLength(MAX_RESOURCE_CANVAS_HISTORY_STEPS);
|
||||
expect(history.undoStack[0]?.label).toBe('移动 5');
|
||||
|
||||
const undone = undoResourceCanvasHistory(
|
||||
history,
|
||||
captureResourceCanvasSnapshot(positions([['asset:a', 99, 99]])),
|
||||
)!;
|
||||
expect(canRedoResourceCanvasHistory(undone.history)).toBe(true);
|
||||
|
||||
const pushed = pushResourceCanvasHistory(undone.history, {
|
||||
label: '新的移动',
|
||||
snapshot: captureResourceCanvasSnapshot(
|
||||
positions([['asset:a', 120, 120]]),
|
||||
),
|
||||
});
|
||||
expect(canRedoResourceCanvasHistory(pushed)).toBe(false);
|
||||
});
|
||||
|
||||
it('撤销与重做互为逆操作,并回填当前快照', () => {
|
||||
const moved = captureResourceCanvasSnapshot(
|
||||
positions([['asset:a', 10, 20]]),
|
||||
);
|
||||
let history = pushResourceCanvasHistory(createResourceCanvasHistory(), {
|
||||
label: '移动卡片',
|
||||
snapshot: moved,
|
||||
});
|
||||
|
||||
const current = captureResourceCanvasSnapshot(
|
||||
positions([['asset:a', 60, 70]]),
|
||||
);
|
||||
const undone = undoResourceCanvasHistory(history, current)!;
|
||||
expect(undone.snapshot).toEqual(moved);
|
||||
expect(undone.label).toBe('移动卡片');
|
||||
expect(canUndoResourceCanvasHistory(undone.history)).toBe(false);
|
||||
|
||||
const redone = redoResourceCanvasHistory(undone.history, moved)!;
|
||||
expect(redone.snapshot).toEqual(current);
|
||||
expect(canRedoResourceCanvasHistory(redone.history)).toBe(false);
|
||||
expect(canUndoResourceCanvasHistory(redone.history)).toBe(true);
|
||||
history = redone.history;
|
||||
expect(history.redoStack).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('空历史撤销/重做返回 null,清空后两个栈都为空', () => {
|
||||
const empty = createResourceCanvasHistory();
|
||||
expect(undoResourceCanvasHistory(empty, { entries: [] })).toBeNull();
|
||||
expect(redoResourceCanvasHistory(empty, { entries: [] })).toBeNull();
|
||||
|
||||
const history = pushResourceCanvasHistory(empty, {
|
||||
label: '移动卡片',
|
||||
snapshot: captureResourceCanvasSnapshot(positions([['asset:a', 1, 1]])),
|
||||
});
|
||||
const cleared = clearResourceCanvasHistory(history);
|
||||
expect(cleared.undoStack).toHaveLength(0);
|
||||
expect(cleared.redoStack).toHaveLength(0);
|
||||
expect(clearResourceCanvasHistory(cleared)).toBe(cleared);
|
||||
});
|
||||
|
||||
it('只把与当前不一致的位置写回后端', () => {
|
||||
const snapshot = captureResourceCanvasSnapshot(
|
||||
positions([
|
||||
['asset:a', 10, 20],
|
||||
['asset:b', 30, 40],
|
||||
]),
|
||||
);
|
||||
const restore = resolveResourceCanvasRestoreEntries(
|
||||
snapshot,
|
||||
positions([
|
||||
['asset:a', 10, 20],
|
||||
['asset:b', 99, 98],
|
||||
['asset:c', 1, 1],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(restore.map((entry) => entry.resourceId)).toEqual(['asset:b']);
|
||||
});
|
||||
|
||||
it('快照里不存在的资源不会在撤销时被新建出来', () => {
|
||||
const snapshot = captureResourceCanvasSnapshot(
|
||||
positions([['asset:deleted', 10, 20]]),
|
||||
);
|
||||
expect(
|
||||
resolveResourceCanvasRestoreEntries(snapshot, positions([])),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user