合并 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",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "main",
|
"identifier": "main",
|
||||||
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件。",
|
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
|
||||||
"windows": ["client"],
|
"windows": ["client"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"clipboard-manager:allow-read-image",
|
"clipboard-manager:allow-read-image",
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"opener:default",
|
"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)
|
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]
|
#[tauri::command]
|
||||||
pub(crate) async fn read_local_project_image_preview(
|
pub(crate) async fn read_local_project_image_preview(
|
||||||
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
|
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
|
||||||
|
|||||||
@@ -2648,6 +2648,7 @@ fn main() {
|
|||||||
import_local_project_image_assets,
|
import_local_project_image_assets,
|
||||||
read_local_project_file,
|
read_local_project_file,
|
||||||
read_local_project_image_preview,
|
read_local_project_image_preview,
|
||||||
|
save_local_project_asset_file,
|
||||||
read_local_project_text_preview,
|
read_local_project_text_preview,
|
||||||
read_local_project_media_preview,
|
read_local_project_media_preview,
|
||||||
cancel_local_project_resource_preview_scope,
|
cancel_local_project_resource_preview_scope,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use similar::TextDiff;
|
|||||||
use std::io::{Seek, SeekFrom};
|
use std::io::{Seek, SeekFrom};
|
||||||
|
|
||||||
mod agent_db;
|
mod agent_db;
|
||||||
|
mod asset_export;
|
||||||
mod asset_rename;
|
mod asset_rename;
|
||||||
mod checkpoint;
|
mod checkpoint;
|
||||||
mod conversation;
|
mod conversation;
|
||||||
@@ -18,6 +19,7 @@ mod resource_layout;
|
|||||||
mod verification;
|
mod verification;
|
||||||
|
|
||||||
pub(crate) use agent_db::*;
|
pub(crate) use agent_db::*;
|
||||||
|
pub(crate) use asset_export::*;
|
||||||
pub(crate) use asset_rename::*;
|
pub(crate) use asset_rename::*;
|
||||||
pub(crate) use checkpoint::*;
|
pub(crate) use checkpoint::*;
|
||||||
pub(crate) use conversation::*;
|
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;
|
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,
|
CanvasChromeButton,
|
||||||
SelectionOverlay,
|
SelectionOverlay,
|
||||||
} from '@genarrative/image-canvas-react';
|
} from '@genarrative/image-canvas-react';
|
||||||
|
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import {
|
import {
|
||||||
AtSign,
|
AtSign,
|
||||||
Code2,
|
Code2,
|
||||||
@@ -30,11 +31,13 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
|
Redo2,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Search,
|
Search,
|
||||||
Settings2,
|
Settings2,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Undo2,
|
||||||
X,
|
X,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -86,6 +89,25 @@ import {
|
|||||||
type ResourceReferenceFilter,
|
type ResourceReferenceFilter,
|
||||||
} from '../../features/project-workspace/resourceReferences';
|
} from '../../features/project-workspace/resourceReferences';
|
||||||
import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker';
|
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 { createResourceQuickEditPanelDraft } from '../../features/resource-canvas/resourceCanvasQuickEditModel';
|
||||||
import {
|
import {
|
||||||
canNormalizeResourceIntoManifestAsset,
|
canNormalizeResourceIntoManifestAsset,
|
||||||
@@ -1220,6 +1242,13 @@ export default function ProjectDevelopmentView({
|
|||||||
useState<QuickEditPanelState | null>(null);
|
useState<QuickEditPanelState | null>(null);
|
||||||
const [resourceCanvasMarquee, setResourceCanvasMarquee] =
|
const [resourceCanvasMarquee, setResourceCanvasMarquee] =
|
||||||
useState<CanvasMarqueeState | null>(null);
|
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>(
|
const [uiEditorRoute, setUiEditorRoute] = useState<UiEditorRoute | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -3074,6 +3103,7 @@ export default function ProjectDevelopmentView({
|
|||||||
setSelectedResourceIds([]);
|
setSelectedResourceIds([]);
|
||||||
setQuickEditPanel(null);
|
setQuickEditPanel(null);
|
||||||
setQuickEditSourceLayer(null);
|
setQuickEditSourceLayer(null);
|
||||||
|
setResourceCanvasHistory(clearResourceCanvasHistory);
|
||||||
setActiveResourceCategory(null);
|
setActiveResourceCategory(null);
|
||||||
const defaultViewports = defaultResourceCanvasViewports();
|
const defaultViewports = defaultResourceCanvasViewports();
|
||||||
resourceCanvasViewportTargetsRef.current = defaultViewports;
|
resourceCanvasViewportTargetsRef.current = defaultViewports;
|
||||||
@@ -3354,6 +3384,246 @@ export default function ProjectDevelopmentView({
|
|||||||
}
|
}
|
||||||
}, [resourceCardPreviews.previews, stopActiveCardMedia]);
|
}, [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(
|
const handleResourceSelect = useCallback(
|
||||||
(resourceId: string, options: { append?: boolean } = {}) => {
|
(resourceId: string, options: { append?: boolean } = {}) => {
|
||||||
if (skipNextResourceCardClickRef.current === resourceId) {
|
if (skipNextResourceCardClickRef.current === resourceId) {
|
||||||
@@ -3699,6 +3969,15 @@ export default function ProjectDevelopmentView({
|
|||||||
: null;
|
: null;
|
||||||
setResourceCardDragPreview(null);
|
setResourceCardDragPreview(null);
|
||||||
if (commit && drag.changed && preview?.resourceId === drag.resourceId) {
|
if (commit && drag.changed && preview?.resourceId === drag.resourceId) {
|
||||||
|
// 先落"拖动前"的快照,撤销才有回退点;它只含布局坐标。
|
||||||
|
setResourceCanvasHistory((history) =>
|
||||||
|
pushResourceCanvasHistory(history, {
|
||||||
|
label: '移动资源卡',
|
||||||
|
snapshot: captureResourceCanvasSnapshot(
|
||||||
|
activeResourceLayout.layout.positions,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
activeResourceLayout.commitPosition(
|
activeResourceLayout.commitPosition(
|
||||||
drag.resourceId,
|
drag.resourceId,
|
||||||
drag.section,
|
drag.section,
|
||||||
@@ -4937,6 +5216,19 @@ export default function ProjectDevelopmentView({
|
|||||||
) : null}
|
) : null}
|
||||||
{mode === 'resources' && !uiEditorRoute ? (
|
{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 ||
|
{pendingResourceEdits.length > 0 ||
|
||||||
pendingResourceEditsLoadState === 'failed' ? (
|
pendingResourceEditsLoadState === 'failed' ? (
|
||||||
<button
|
<button
|
||||||
@@ -5150,6 +5442,26 @@ export default function ProjectDevelopmentView({
|
|||||||
onWheel={handleResourceBookWheel}
|
onWheel={handleResourceBookWheel}
|
||||||
/>
|
/>
|
||||||
<div className="game-resource-book-zoom">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="game-resource-book-zoom-button"
|
className="game-resource-book-zoom-button"
|
||||||
@@ -5653,6 +5965,32 @@ export default function ProjectDevelopmentView({
|
|||||||
</footer>
|
</footer>
|
||||||
) : null}
|
) : 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 ? (
|
{resourceClassificationAsset ? (
|
||||||
<ResourceClassificationPanel
|
<ResourceClassificationPanel
|
||||||
key={resourceClassificationAsset.id}
|
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