修复AGC画布交互并识别UI设计JSON
稳定工作台与窗口标题栏状态同步,消除重复更新循环 支持画布右键平移并保留左键框选及资源拖动,完善中断清理 解耦运行不可用提示与资源选中状态 复用原生UI状态校验区分UI设计JSON和普通JSON,接入现有编辑器与代码预览 补充前端和原生回归测试、规范及待验收记录,明确对话历史分页尚未修复
This commit is contained in:
@@ -5008,7 +5008,21 @@ pub(crate) fn read_local_project_text_preview_at(
|
||||
return Err("只能读取当前项目已登记的文档资源".to_string());
|
||||
}
|
||||
cancellation.check()?;
|
||||
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)
|
||||
let mut preview =
|
||||
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)?;
|
||||
if normalized_path.to_ascii_lowercase().ends_with(".json") {
|
||||
preview.ui_design_asset_id = manifest.assets.iter().find_map(|asset| {
|
||||
(asset.local_path == normalized_path
|
||||
&& ui_editor::persistence::is_valid_ui_design_json(
|
||||
&preview.content,
|
||||
&manifest.project_id,
|
||||
&asset.id,
|
||||
))
|
||||
.then(|| asset.id.clone())
|
||||
});
|
||||
}
|
||||
cancellation.check()?;
|
||||
Ok(preview)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -24,6 +24,9 @@ pub(crate) struct LocalProjectTextPreview {
|
||||
pub(crate) media_type: String,
|
||||
pub(crate) byte_len: u64,
|
||||
pub(crate) content: String,
|
||||
/// 仅由已登记资源的原生 UI State 校验设置;前端不根据正文猜测编辑能力。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) ui_design_asset_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
@@ -163,6 +166,7 @@ pub(crate) fn load_local_project_text_preview_with_cancellation(
|
||||
media_type: media_type.to_string(),
|
||||
byte_len: content.len() as u64,
|
||||
content,
|
||||
ui_design_asset_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ pub(crate) fn load_ui_design_state_at(
|
||||
let root = Path::new(input.project_path.trim());
|
||||
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
|
||||
let asset_id = required_identifier(&input.asset_id, "assetId")?;
|
||||
let asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
|
||||
let asset = registered_json_asset(root, &expected_project_id, &asset_id)?;
|
||||
let document =
|
||||
read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?;
|
||||
validate_document(&document, &expected_project_id, &asset_id)?;
|
||||
@@ -175,7 +175,7 @@ pub(crate) fn generate_ui_design_code_at(
|
||||
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
|
||||
let asset_id = required_identifier(&input.asset_id, "assetId")?;
|
||||
let _lock = acquire_project_write_lock(root, "ui_design.code_generate")?;
|
||||
let asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
|
||||
let asset = registered_json_asset(root, &expected_project_id, &asset_id)?;
|
||||
let document =
|
||||
read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?;
|
||||
let (content, tree_exports, node_count) = render_ui_design_state_js(&document.state)?;
|
||||
@@ -225,9 +225,9 @@ pub(crate) fn save_ui_design_state_at(
|
||||
let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?;
|
||||
let asset_id = required_identifier(&input.asset_id, "assetId")?;
|
||||
|
||||
let preflight_asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
|
||||
let preflight_asset = registered_json_asset(root, &expected_project_id, &asset_id)?;
|
||||
let _lock = acquire_project_write_lock(root, "ui_design.state_save")?;
|
||||
let asset = ui_design_asset(root, &expected_project_id, &asset_id)?;
|
||||
let asset = registered_json_asset(root, &expected_project_id, &asset_id)?;
|
||||
if asset.local_path != preflight_asset.local_path {
|
||||
return Err("UI 设计资源在保存锁获取期间发生变化,请重试".to_string());
|
||||
}
|
||||
@@ -311,6 +311,19 @@ fn ui_design_asset(
|
||||
root: &Path,
|
||||
expected_project_id: &str,
|
||||
asset_id: &str,
|
||||
) -> Result<GameCreationAppAssetManifestEntry, String> {
|
||||
let asset = registered_json_asset(root, expected_project_id, asset_id)?;
|
||||
// 新状态初始化仍是显式 UI 创建动作,不能因放开已有设计的登记标签而覆盖普通 JSON。
|
||||
if asset.kind != "UI" || asset.media_type != "application/json" {
|
||||
return Err("目标资源不是 UI 设计 JSON 资产".to_string());
|
||||
}
|
||||
Ok(asset)
|
||||
}
|
||||
|
||||
fn registered_json_asset(
|
||||
root: &Path,
|
||||
expected_project_id: &str,
|
||||
asset_id: &str,
|
||||
) -> Result<GameCreationAppAssetManifestEntry, String> {
|
||||
let manifest = read_existing_manifest_for_project(root)?;
|
||||
if manifest.project_id != expected_project_id {
|
||||
@@ -321,8 +334,10 @@ fn ui_design_asset(
|
||||
.into_iter()
|
||||
.find(|asset| asset.id == asset_id)
|
||||
.ok_or_else(|| "UI 设计资源不存在".to_string())?;
|
||||
if asset.kind != "UI" || asset.media_type != "application/json" {
|
||||
return Err("目标资源不是 UI 设计 JSON 资产".to_string());
|
||||
if !asset.local_path.to_ascii_lowercase().ends_with(".json")
|
||||
|| !is_supported_project_text_resource(&asset.local_path, &asset.media_type)
|
||||
{
|
||||
return Err("目标资源不是已登记的 JSON 资产".to_string());
|
||||
}
|
||||
normalize_relative_path(&asset.local_path)?;
|
||||
Ok(asset)
|
||||
@@ -393,10 +408,27 @@ fn read_ui_design_document_path(path: &Path) -> Result<PersistedUiDesignState, S
|
||||
"UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限"
|
||||
));
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("解析 UI 设计 State 失败:{}: {error}", path.display()))?;
|
||||
parse_ui_design_document(&bytes)
|
||||
.map_err(|error| format!("读取 UI 设计 State 失败:{}: {error}", path.display()))
|
||||
}
|
||||
|
||||
/// 复用编辑器完整契约识别已读取的 JSON;不重读文件,也不触发恢复或任何写入。
|
||||
pub(crate) fn is_valid_ui_design_json(content: &str, project_id: &str, asset_id: &str) -> bool {
|
||||
parse_ui_design_document(content.as_bytes())
|
||||
.and_then(|document| validate_document(&document, project_id, asset_id))
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn parse_ui_design_document(bytes: &[u8]) -> Result<PersistedUiDesignState, String> {
|
||||
if bytes.len() > UI_DESIGN_STATE_MAX_BYTES {
|
||||
return Err(format!(
|
||||
"UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限"
|
||||
));
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_slice(bytes)
|
||||
.map_err(|error| format!("解析 UI 设计 State 失败:{error}"))?;
|
||||
let document: PersistedUiDesignState = serde_json::from_value(value.clone())
|
||||
.map_err(|error| format!("解析 UI 设计 State 契约失败:{}: {error}", path.display()))?;
|
||||
.map_err(|error| format!("解析 UI 设计 State 契约失败:{error}"))?;
|
||||
let canonical: serde_json::Value =
|
||||
serde_json::from_slice(&serialize_ui_design_document(&document)?)
|
||||
.map_err(|error| format!("序列化 UI 设计 State 契约失败:{error}"))?;
|
||||
@@ -847,6 +879,170 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_preview_recognition_requires_canonical_state_and_resource_identity() {
|
||||
let document = serde_json::to_value(empty_document(PROJECT_ID, "design")).unwrap();
|
||||
let content = document.to_string();
|
||||
assert!(is_valid_ui_design_json(&content, PROJECT_ID, "design"));
|
||||
assert!(!is_valid_ui_design_json(
|
||||
&content,
|
||||
"other-project",
|
||||
"design"
|
||||
));
|
||||
assert!(!is_valid_ui_design_json(
|
||||
&content,
|
||||
PROJECT_ID,
|
||||
"other-asset"
|
||||
));
|
||||
for content in ["{broken", "{}", "[]", r#"{"type":"UI"}"#] {
|
||||
assert!(!is_valid_ui_design_json(content, PROJECT_ID, "design"));
|
||||
}
|
||||
for (pointer, value) in [
|
||||
("/schemaVersion", serde_json::json!("unknown-schema")),
|
||||
("/revision", serde_json::json!(9_007_199_254_740_992u64)),
|
||||
("/state/ui_trees", serde_json::json!([{}])),
|
||||
] {
|
||||
let mut invalid = document.clone();
|
||||
*invalid.pointer_mut(pointer).unwrap() = value;
|
||||
assert!(!is_valid_ui_design_json(
|
||||
&invalid.to_string(),
|
||||
PROJECT_ID,
|
||||
"design"
|
||||
));
|
||||
}
|
||||
let mut unknown = document.clone();
|
||||
unknown["state"]["unknown"] = serde_json::json!(true);
|
||||
assert!(!is_valid_ui_design_json(
|
||||
&unknown.to_string(),
|
||||
PROJECT_ID,
|
||||
"design"
|
||||
));
|
||||
let invalid_state = PersistedUiDesignState {
|
||||
state: state_with_unavailable_image("../outside.png"),
|
||||
..empty_document(PROJECT_ID, "design")
|
||||
};
|
||||
assert!(!is_valid_ui_design_json(
|
||||
&serde_json::to_string(&invalid_state).unwrap(),
|
||||
PROJECT_ID,
|
||||
"design",
|
||||
));
|
||||
assert!(!is_valid_ui_design_json(
|
||||
&" ".repeat(UI_DESIGN_STATE_MAX_BYTES + 1),
|
||||
PROJECT_ID,
|
||||
"design",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_preview_and_editor_accept_valid_state_without_rewriting_asset_kind() {
|
||||
for kind in ["UI", "ui", "ui-design", "document"] {
|
||||
let (directory, asset_id) = fixture();
|
||||
crate::project::mutate_manifest_at(directory.path(), |manifest| {
|
||||
manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
.find(|asset| asset.id == asset_id)
|
||||
.unwrap()
|
||||
.kind = kind.to_string();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let path = directory.path().join("ui/design.json");
|
||||
let before = fs::read(&path).unwrap();
|
||||
let preview = read_local_project_text_preview_at(
|
||||
directory.path().to_str().unwrap(),
|
||||
"ui/design.json",
|
||||
&crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(),
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
preview.ui_design_asset_id.as_deref(),
|
||||
Some(asset_id.as_str())
|
||||
);
|
||||
assert_eq!(fs::read(&path).unwrap(), before);
|
||||
let loaded = load_ui_design_state_at(LoadUiDesignStateInput {
|
||||
project_path: directory.path().to_string_lossy().into_owned(),
|
||||
expected_project_id: PROJECT_ID.to_string(),
|
||||
asset_id: asset_id.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(loaded.revision, 0);
|
||||
let saved = save_ui_design_state_at(input(
|
||||
directory.path(),
|
||||
&asset_id,
|
||||
0,
|
||||
state_with_unavailable_image("assets/page.png"),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
saved,
|
||||
SaveUiDesignStateResult::Saved { revision: 1, .. }
|
||||
));
|
||||
assert_eq!(
|
||||
read_existing_manifest_for_project(directory.path())
|
||||
.unwrap()
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.id == asset_id)
|
||||
.unwrap()
|
||||
.kind,
|
||||
kind,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_or_foreign_json_preview_never_grants_ui_editing_or_overwrites_content() {
|
||||
let (directory, asset_id) = fixture();
|
||||
crate::project::mutate_manifest_at(directory.path(), |manifest| {
|
||||
manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
.find(|asset| asset.id == asset_id)
|
||||
.unwrap()
|
||||
.kind = "document".to_string();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let path = directory.path().join("ui/design.json");
|
||||
let foreign = serde_json::to_string(&empty_document("other-project", &asset_id)).unwrap();
|
||||
for content in [r#"{"ordinary":true}"#, "{broken", foreign.as_str()] {
|
||||
fs::write(&path, content).unwrap();
|
||||
let preview = read_local_project_text_preview_at(
|
||||
directory.path().to_str().unwrap(),
|
||||
"ui/design.json",
|
||||
&crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(),
|
||||
).unwrap();
|
||||
assert_eq!(preview.ui_design_asset_id, None);
|
||||
assert_eq!(preview.content, content);
|
||||
assert!(load_ui_design_state_at(LoadUiDesignStateInput {
|
||||
project_path: directory.path().to_string_lossy().into_owned(),
|
||||
expected_project_id: PROJECT_ID.to_string(),
|
||||
asset_id: asset_id.clone(),
|
||||
})
|
||||
.is_err());
|
||||
assert!(save_ui_design_state_at(input(
|
||||
directory.path(),
|
||||
&asset_id,
|
||||
0,
|
||||
empty_document(PROJECT_ID, &asset_id).state,
|
||||
))
|
||||
.is_err());
|
||||
assert!(
|
||||
initialize_ui_design_state_at(directory.path(), PROJECT_ID, &asset_id).is_err()
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
fs::write(
|
||||
directory.path().join("ui/unregistered.json"),
|
||||
serde_json::to_string(&empty_document(PROJECT_ID, &asset_id)).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(read_local_project_text_preview_at(
|
||||
directory.path().to_str().unwrap(), "ui/unregistered.json",
|
||||
&crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(),
|
||||
).is_err());
|
||||
}
|
||||
|
||||
fn state_with_unavailable_image(path: &str) -> State {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"ui_trees": [],
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { launcherNotifications } from '../../app/constants';
|
||||
@@ -198,12 +205,18 @@ export function WorkspaceLauncherShell({
|
||||
*/
|
||||
const manifestMergeNoticeScopeRef = useRef<string | null>(null);
|
||||
|
||||
// 打开项目由创建流程提供,其函数引用随渲染变化;标题栏只持有稳定的转发入口,
|
||||
// 否则发布 Context 会再次触发工作台 effect,形成发布/清理循环。
|
||||
const openProjectRef = useRef(openProject);
|
||||
useLayoutEffect(() => {
|
||||
openProjectRef.current = openProject;
|
||||
}, [openProject]);
|
||||
const openActiveProject = useCallback(
|
||||
(nextProjectPath: string) => {
|
||||
setProjectPath(nextProjectPath);
|
||||
void openProject(nextProjectPath, 'open');
|
||||
void openProjectRef.current(nextProjectPath, 'open');
|
||||
},
|
||||
[openProject, setProjectPath],
|
||||
[setProjectPath],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -213,7 +226,6 @@ export function WorkspaceLauncherShell({
|
||||
readFailed: snapshotReadFailed,
|
||||
onOpenProject: openActiveProject,
|
||||
});
|
||||
return () => setActiveProjectRuns(null);
|
||||
}, [
|
||||
activeTurns,
|
||||
currentProjectContext?.projectPath,
|
||||
@@ -221,6 +233,7 @@ export function WorkspaceLauncherShell({
|
||||
setActiveProjectRuns,
|
||||
snapshotReadFailed,
|
||||
]);
|
||||
useEffect(() => () => setActiveProjectRuns(null), [setActiveProjectRuns]);
|
||||
|
||||
useEffect(() => {
|
||||
const projectPath = currentProjectContext?.projectPath ?? null;
|
||||
|
||||
@@ -37,6 +37,23 @@ export function isResourceCanvasInteractionTarget(
|
||||
return Boolean(target?.closest(RESOURCE_CANVAS_INTERACTION_SELECTOR));
|
||||
}
|
||||
|
||||
/** 抓手可从卡面发起,但不能抢走输入、媒体控件或画布浮层的交互。 */
|
||||
export function isResourceCanvasPanTarget(
|
||||
target: Element | null | undefined,
|
||||
): boolean {
|
||||
if (!target) return false;
|
||||
if (target.closest('[contenteditable="true"], .game-resource-filter-panel')) {
|
||||
return false;
|
||||
}
|
||||
if (!isResourceCanvasInteractionTarget(target)) return true;
|
||||
return Boolean(
|
||||
target.closest('.game-resource-card') &&
|
||||
!target.closest(
|
||||
'button:not(.game-resource-card-select), input, textarea, select, a, audio, video',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 画布浮层里有自己滚动区的那几个:落在它们里面的滚轮归浮层,画布不得消费。
|
||||
*
|
||||
|
||||
+7
-3
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
isProjectResourceJson,
|
||||
projectResourceCardPreviewKind,
|
||||
projectResourcePathExtension,
|
||||
} from '../../view/project-development/resourceCardPreviewModel';
|
||||
@@ -65,11 +66,14 @@ export function resourceDocumentPreviewMarkdown(
|
||||
resource: ProjectResource,
|
||||
content: string,
|
||||
) {
|
||||
if (projectResourceCardPreviewKind(resource) !== 'code') {
|
||||
const isJson = isProjectResourceJson(resource);
|
||||
if (projectResourceCardPreviewKind(resource) !== 'code' && !isJson) {
|
||||
return content;
|
||||
}
|
||||
const language =
|
||||
CODE_LANGUAGES[projectResourcePathExtension(resource.path) ?? ''] ?? 'text';
|
||||
const language = isJson
|
||||
? 'json'
|
||||
: (CODE_LANGUAGES[projectResourcePathExtension(resource.path) ?? ''] ??
|
||||
'text');
|
||||
// 围栏长于源码里的任意反引号串,代码生成模板中的 Markdown 不能提前闭合代码块。
|
||||
const longestRun = (content.match(/`+/g) ?? []).reduce(
|
||||
(length, run) => Math.max(length, run.length),
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { FileCode2, Image as ImageIcon, Music2, Video } from 'lucide-react';
|
||||
import {
|
||||
FileCode2,
|
||||
Image as ImageIcon,
|
||||
Music2,
|
||||
SlidersHorizontal,
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import {
|
||||
projectResourceCardPreviewKind,
|
||||
type ProjectResourceCardPreviewState,
|
||||
projectResourceCardPreviewVariant,
|
||||
projectResourceJsonPresentation,
|
||||
} from './resourceCardPreviewModel';
|
||||
import type { ProjectResource } from './resourceProjectionModel';
|
||||
|
||||
@@ -90,6 +97,16 @@ export function ResourcePreviewMedia({
|
||||
const sourceUrl =
|
||||
preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null;
|
||||
const visual = (() => {
|
||||
const jsonPresentation = resource
|
||||
? projectResourceJsonPresentation(resource, preview)
|
||||
: null;
|
||||
if (jsonPresentation) {
|
||||
return jsonPresentation === 'ui-design' ? (
|
||||
<SlidersHorizontal className="h-5 w-5" aria-label="UI 设计" />
|
||||
) : (
|
||||
<FileCode2 className="h-5 w-5" aria-label="JSON" />
|
||||
);
|
||||
}
|
||||
if (sourceUrl && (kind === 'raster-image' || kind === 'media-image')) {
|
||||
return (
|
||||
<img
|
||||
|
||||
@@ -154,6 +154,7 @@ import { ResourceCanvasBottomToolbarView } from '../../features/resource-canvas/
|
||||
import {
|
||||
canDismissResourceCanvasQuickEdit,
|
||||
isResourceCanvasInteractionTarget,
|
||||
isResourceCanvasPanTarget,
|
||||
isResourceCanvasWheelOverlayTarget,
|
||||
resolveResourceCanvasFloatingPanelDismissOpen,
|
||||
resolveResourceCanvasFloatingPanelOpen,
|
||||
@@ -297,6 +298,7 @@ import {
|
||||
projectResourceCardPreviewVariant,
|
||||
projectResourceCodeTypeLabel,
|
||||
projectResourceDocumentPreviewText,
|
||||
projectResourceJsonPresentation,
|
||||
} from './resourceCardPreviewModel';
|
||||
import { ResourceClassificationPanel } from './ResourceClassificationPanel';
|
||||
import {
|
||||
@@ -766,8 +768,8 @@ const ResourceCard = memo(function ResourceCard({
|
||||
*
|
||||
* 取值口径与画布栏目完全同源:`resource.category` 就是这张卡在画布上所属的栏目,
|
||||
* `categoryLabels` 是栏目与筛选共用的同一份中文展示名(`resourceReferenceCategoryLabel`
|
||||
* 加末尾「项目版本」),所以角标文案恒等于它所在的栏目名 —— 不再是"图片 / 视频 / 文档"
|
||||
* 那套媒体口径。媒体类型仍由卡面视觉(图片 / 视频 / 音频 / 文档摘要)表达,信息没有丢失。
|
||||
* 加末尾「项目版本」)。JSON 单独显示原生识别后的 UI 设计 / JSON,
|
||||
* 但不改变栏目、筛选或 manifest 分类;其它资源继续使用栏目标签。
|
||||
*/
|
||||
const resourceCategoryLabel = categoryLabels[resource.category];
|
||||
const isMedia = kind === 'video' || kind === 'audio';
|
||||
@@ -781,9 +783,17 @@ const ResourceCard = memo(function ResourceCard({
|
||||
* 等读完再决定画什么只会把"丑预览"和多余 IO 一起留在链路上。
|
||||
*/
|
||||
const previewVariant = projectResourceCardPreviewVariant(resource);
|
||||
const jsonPresentation = projectResourceJsonPresentation(resource, preview);
|
||||
const cardTypeLabel =
|
||||
jsonPresentation === 'ui-design'
|
||||
? 'UI 设计'
|
||||
: jsonPresentation === 'json'
|
||||
? 'JSON'
|
||||
: resourceCategoryLabel;
|
||||
/** 代码卡的类型标签(`.tsx` → `TSX`);不是代码卡时为 `null`。 */
|
||||
const codeTypeLabel = projectResourceCodeTypeLabel(resource.path);
|
||||
const documentPreview =
|
||||
!jsonPresentation &&
|
||||
preview.status === 'loaded' &&
|
||||
preview.preview.content !== undefined &&
|
||||
previewVariant !== null &&
|
||||
@@ -822,6 +832,20 @@ const ResourceCard = memo(function ResourceCard({
|
||||
}, [previewIdentity, sourceUrl]);
|
||||
|
||||
const visual = (() => {
|
||||
if (jsonPresentation) {
|
||||
return (
|
||||
<span className="game-resource-card-code-visual">
|
||||
{jsonPresentation === 'ui-design' ? (
|
||||
<SlidersHorizontal size={30} aria-hidden="true" />
|
||||
) : (
|
||||
<FileCode2 size={30} aria-hidden="true" />
|
||||
)}
|
||||
<span className="game-resource-card-code-label">
|
||||
{jsonPresentation === 'ui-design' ? 'UI 编辑器' : 'JSON'}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if ((kind === 'raster-image' || kind === 'media-image') && sourceUrl) {
|
||||
return (
|
||||
<img
|
||||
@@ -997,6 +1021,7 @@ const ResourceCard = memo(function ResourceCard({
|
||||
}`}
|
||||
data-resource-card-id={resource.id}
|
||||
data-preview-kind={kind}
|
||||
data-json-presentation={jsonPresentation ?? undefined}
|
||||
// 占位分支对「还没读 / 读失败 / 压根不适用」给的是同一个图标、且卡面没有文字提示,
|
||||
// 现场无法区分「为什么没有图」。这里把预览状态与失败原因暴露到 DOM,
|
||||
// 让排障只需读一个属性,而不是去猜是调度没发请求还是原生拒绝了读取。
|
||||
@@ -1049,10 +1074,10 @@ const ResourceCard = memo(function ResourceCard({
|
||||
</span>
|
||||
<span
|
||||
className="game-resource-card-type-badge"
|
||||
data-resource-type={resourceCategoryLabel}
|
||||
title={resourceCategoryLabel}
|
||||
data-resource-type={cardTypeLabel}
|
||||
title={cardTypeLabel}
|
||||
>
|
||||
{resourceCategoryLabel}
|
||||
{cardTypeLabel}
|
||||
</span>
|
||||
{lineage ? (
|
||||
// 文字是给人看的关系,`data-resource-lineage` 是给端到端验收的稳定判据
|
||||
@@ -1342,6 +1367,7 @@ function ResourceBookScene({
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerCancel}
|
||||
onLostPointerCapture={onPointerCancel}
|
||||
>
|
||||
<div
|
||||
className="game-resource-book-scene-world"
|
||||
@@ -1485,6 +1511,7 @@ export default function ProjectDevelopmentView({
|
||||
});
|
||||
const resourceBookMainPanRef = useRef<{
|
||||
pointerId: number;
|
||||
captureTarget: HTMLElement;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startViewport: CanvasViewport;
|
||||
@@ -1771,6 +1798,7 @@ export default function ProjectDevelopmentView({
|
||||
const resourceCanvasFitKeysRef = useRef<Set<string>>(new Set());
|
||||
const resourceCanvasPanRef = useRef<{
|
||||
pointerId: number;
|
||||
captureTarget: HTMLElement;
|
||||
category: ResourceBookTarget;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
@@ -3363,15 +3391,14 @@ export default function ProjectDevelopmentView({
|
||||
);
|
||||
|
||||
const cancelResourceCanvasPan = useCallback(() => {
|
||||
const pan = resourceCanvasPanRef.current;
|
||||
if (!pan) {
|
||||
return;
|
||||
}
|
||||
const canvas = resourceCanvasRef.current;
|
||||
if (canvas?.hasPointerCapture?.(pan.pointerId)) {
|
||||
canvas.releasePointerCapture?.(pan.pointerId);
|
||||
}
|
||||
const pans = [resourceCanvasPanRef.current, resourceBookMainPanRef.current];
|
||||
resourceCanvasPanRef.current = null;
|
||||
resourceBookMainPanRef.current = null;
|
||||
for (const pan of pans) {
|
||||
if (pan?.captureTarget.hasPointerCapture?.(pan.pointerId)) {
|
||||
pan.captureTarget.releasePointerCapture(pan.pointerId);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancelResourceCardDrag = useCallback(() => {
|
||||
@@ -4749,6 +4776,8 @@ export default function ProjectDevelopmentView({
|
||||
};
|
||||
const handleBlur = () => {
|
||||
resourceCanvasSpacePanRef.current = false;
|
||||
cancelResourceCanvasPan();
|
||||
setResourceCanvasMarquee(null);
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
@@ -4758,7 +4787,7 @@ export default function ProjectDevelopmentView({
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
};
|
||||
}, []);
|
||||
}, [cancelResourceCanvasPan]);
|
||||
|
||||
const handleResourceBookWheel = useCallback(
|
||||
(event: ReactWheelEvent<HTMLDivElement> | WheelEvent) => {
|
||||
@@ -4855,7 +4884,10 @@ export default function ProjectDevelopmentView({
|
||||
|
||||
const handleResourceBookMainPointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (resourceBookView !== 'main' || event.button !== 0) {
|
||||
if (
|
||||
resourceBookView !== 'main' ||
|
||||
(event.button !== 0 && event.button !== 2)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as HTMLElement;
|
||||
@@ -4871,6 +4903,7 @@ export default function ProjectDevelopmentView({
|
||||
resourceBookTransitionControllerRef.current.settle();
|
||||
resourceBookMainPanRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
captureTarget: event.currentTarget,
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startViewport: resourceBookMainViewportRef.current,
|
||||
@@ -5096,11 +5129,19 @@ export default function ProjectDevelopmentView({
|
||||
const canvasTarget = resourceBookOpensAllResources
|
||||
? RESOURCE_BOOK_ALL_TARGET
|
||||
: activePageCategory;
|
||||
if (!canvasTarget || event.button > 1) {
|
||||
if (!canvasTarget || event.button > 2) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as HTMLElement;
|
||||
if (isResourceCanvasInteractionTarget(target)) {
|
||||
const isPan =
|
||||
event.button === 2 ||
|
||||
event.button === 1 ||
|
||||
resourceCanvasSpacePanRef.current;
|
||||
if (
|
||||
event.button === 2
|
||||
? !isResourceCanvasPanTarget(target)
|
||||
: isResourceCanvasInteractionTarget(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// 点选态下空白处的左键不起框选、也不清画布焦点/选中:点选只认资源卡,
|
||||
@@ -5114,12 +5155,13 @@ export default function ProjectDevelopmentView({
|
||||
return;
|
||||
}
|
||||
resourceBookTransitionControllerRef.current.settle();
|
||||
// 与美术画布一致:中键或按住空格是平移,左键在空白处是框选。
|
||||
if (event.button === 1 || resourceCanvasSpacePanRef.current) {
|
||||
// 右键平移,保留中键/空格抓手;空白处左键继续框选。
|
||||
if (isPan) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
resourceCanvasPanRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
captureTarget: event.currentTarget,
|
||||
category: canvasTarget,
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
@@ -5292,12 +5334,22 @@ export default function ProjectDevelopmentView({
|
||||
|
||||
const openResourceUiEditor = useCallback(
|
||||
(resource: ProjectResource) => {
|
||||
if (resource.subtype === 'ui-prototype') {
|
||||
const identity = resourceCardPreviews.identityByResourceId.get(
|
||||
resource.id,
|
||||
);
|
||||
const jsonPresentation = projectResourceJsonPresentation(
|
||||
resource,
|
||||
identity ? resourceCardPreviews.previews.get(identity) : null,
|
||||
);
|
||||
if (resource.subtype === 'ui-prototype' && jsonPresentation === null) {
|
||||
void openUiDesignEditor(resource);
|
||||
return;
|
||||
}
|
||||
if (resource.manifestAssetId === null) {
|
||||
setResourceWorkbenchNotice('该 UI 资源缺少有效的正式资产身份');
|
||||
if (
|
||||
resource.manifestAssetId === null ||
|
||||
jsonPresentation !== 'ui-design'
|
||||
) {
|
||||
setResourceWorkbenchNotice('该资源尚未通过 UI 设计 JSON 校验');
|
||||
return;
|
||||
}
|
||||
canvasOpenEpochRef.current += 1;
|
||||
@@ -5318,7 +5370,13 @@ export default function ProjectDevelopmentView({
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
[advanceFocusGeneration, manifest.assets, openUiDesignEditor],
|
||||
[
|
||||
advanceFocusGeneration,
|
||||
manifest.assets,
|
||||
openUiDesignEditor,
|
||||
resourceCardPreviews.identityByResourceId,
|
||||
resourceCardPreviews.previews,
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -6202,8 +6260,7 @@ export default function ProjectDevelopmentView({
|
||||
setMode('run');
|
||||
}
|
||||
|
||||
const showRunUnavailableHint =
|
||||
!runAvailable && selectedResourceIds.length === 0 && !uiEditorRoute;
|
||||
const showRunUnavailableHint = !runAvailable && !uiEditorRoute;
|
||||
|
||||
const renderResourceBookCard = useCallback(
|
||||
(
|
||||
@@ -7457,9 +7514,16 @@ export default function ProjectDevelopmentView({
|
||||
: null,
|
||||
[manifest, selectedResource],
|
||||
);
|
||||
const selectedResourceJsonPresentation = selectedResource
|
||||
? projectResourceJsonPresentation(
|
||||
selectedResource,
|
||||
selectedResourceCardPreview,
|
||||
)
|
||||
: null;
|
||||
const selectedResourceOpensUiEditor =
|
||||
selectedResource?.subtype === 'UI' ||
|
||||
selectedResource?.subtype === 'ui-prototype';
|
||||
selectedResourceJsonPresentation === 'ui-design' ||
|
||||
(selectedResourceJsonPresentation === null &&
|
||||
selectedResource?.subtype === 'ui-prototype');
|
||||
|
||||
const selectedToolbarStyle = selectedResourceLayer
|
||||
? resolveSelectedToolbarStyle({
|
||||
@@ -7847,10 +7911,24 @@ export default function ProjectDevelopmentView({
|
||||
className={`game-resource-manager game-resource-book-manager game-resource-book-manager--${resourceBookState.view} game-resource-book-manager--${resourceBookState.phase}`}
|
||||
data-resource-book-view={resourceBookState.view}
|
||||
data-resource-book-transition={resourceBookState.phase}
|
||||
onContextMenu={(event) => {
|
||||
const target = event.target as Element;
|
||||
if (
|
||||
event.button === 2 &&
|
||||
event.currentTarget.contains(target) &&
|
||||
target.closest(
|
||||
'.game-resource-book-scene, .game-resource-canvas, .game-resource-book-main',
|
||||
) &&
|
||||
isResourceCanvasPanTarget(target)
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onPointerDownCapture={(event) => {
|
||||
if (
|
||||
event.currentTarget.dataset.resourceBookTransition !==
|
||||
'idle' &&
|
||||
event.button === 0 &&
|
||||
(event.target as Element).closest('.game-resource-card')
|
||||
) {
|
||||
event.preventDefault();
|
||||
@@ -7924,7 +8002,8 @@ export default function ProjectDevelopmentView({
|
||||
<span>预览</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedResourceOpensUiEditor ? (
|
||||
{selectedResource &&
|
||||
selectedResourceOpensUiEditor ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="UI 编辑器"
|
||||
@@ -8505,6 +8584,7 @@ export default function ProjectDevelopmentView({
|
||||
onPointerMove={handleResourceCanvasPointerMove}
|
||||
onPointerUp={stopResourceCanvasPan}
|
||||
onPointerCancel={stopResourceCanvasPan}
|
||||
onLostPointerCapture={stopResourceCanvasPan}
|
||||
>
|
||||
{sortMode === 'dependency' &&
|
||||
dependencyRelationshipDescriptions.length > 0 ? (
|
||||
|
||||
+29
-2
@@ -58,6 +58,8 @@ export type ProjectResourceCardPreviewPayload = {
|
||||
hasAlpha?: boolean;
|
||||
sourceUrl?: string;
|
||||
content?: string;
|
||||
/** 原生侧完整校验过的 UI State 资产身份,不由前端解析 JSON 推断。 */
|
||||
uiDesignAssetId?: string;
|
||||
};
|
||||
|
||||
export type ProjectResourceCardPreviewTransportPayload = Omit<
|
||||
@@ -219,7 +221,8 @@ const markdownExtension = /\.(md|markdown|mdx)$/iu;
|
||||
* 与 `resourceProjectionModel` 的 `gameCodeExtension` 是两份口径,刻意不复用:
|
||||
* 那份用于**筛选与归属**,改动会波及画布栏目与计数;这份只决定**卡面怎么画**。
|
||||
* 这里按用户口径把 `.yaml` / `.toml` / `.xml` / `.html` / `.css` / `.sql`
|
||||
* 一并算代码;JSON 规格属于文档预览。
|
||||
* 一并算代码。JSON 留在文本读取通道,由原生内容识别区分普通 JSON 和 UI 设计,
|
||||
* 不能像源码卡一样跳过预取;卡面不显示 JSON 正文。
|
||||
*/
|
||||
const cardCodeExtension =
|
||||
/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|rs|py|go|java|kt|kts|cs|cpp|cc|cxx|c|h|hpp|swift|php|rb|lua|sh|bash|zsh|ps1|psm1|ya?ml|toml|xml|html?|css|scss|less|sql|graphql|gql|vue|svelte)$/iu;
|
||||
@@ -236,6 +239,30 @@ export function projectResourcePathExtension(path: string): string | null {
|
||||
return matched ? matched[1]!.toLowerCase() : null;
|
||||
}
|
||||
|
||||
export function isProjectResourceJson(
|
||||
resource: Pick<ProjectResource, 'path' | 'mediaType'>,
|
||||
): boolean {
|
||||
return (
|
||||
projectResourcePathExtension(resource.path) === 'json' ||
|
||||
(projectResourcePathExtension(resource.path) === null &&
|
||||
resource.mediaType.toLowerCase().includes('json'))
|
||||
);
|
||||
}
|
||||
|
||||
/** 读取结果必须仍属于当前卡片;元数据中的 UI 标签本身不能授予编辑入口。 */
|
||||
export function projectResourceJsonPresentation(
|
||||
resource: ProjectResource,
|
||||
preview: ProjectResourceCardPreviewState | null | undefined,
|
||||
): 'json' | 'ui-design' | null {
|
||||
if (!isProjectResourceJson(resource)) return null;
|
||||
return resource.manifestAssetId &&
|
||||
preview?.status === 'loaded' &&
|
||||
preview.preview.path === resource.path &&
|
||||
preview.preview.uiDesignAssetId === resource.manifestAssetId
|
||||
? 'ui-design'
|
||||
: 'json';
|
||||
}
|
||||
|
||||
/** 代码文件的类型标签(如 `.ts` → `TS`);不是代码文件时返回 `null`。 */
|
||||
export function projectResourceCodeTypeLabel(path: string): string | null {
|
||||
const trimmed = path.trim();
|
||||
@@ -256,7 +283,7 @@ export function projectResourceCardPreviewKind(
|
||||
return 'document';
|
||||
}
|
||||
// Markdown / 代码在扩展名这一层就分流,不再依赖上游登记类型:
|
||||
// 上游把 JSON 规格登记成「文档」,卡面按文档预览;代码文件按扩展名分流。
|
||||
// JSON 使用文档读取通道完成原生语义识别;源码文件按扩展名分流。
|
||||
if (markdownExtension.test(resource.path)) {
|
||||
return 'document';
|
||||
}
|
||||
|
||||
+1
@@ -167,6 +167,7 @@ function materializeProjectResourceCardPreview(
|
||||
pixelHeight: transport.pixelHeight,
|
||||
hasAlpha: transport.hasAlpha,
|
||||
content: transport.content,
|
||||
uiDesignAssetId: transport.uiDesignAssetId,
|
||||
},
|
||||
retainedBytes:
|
||||
transport.content === undefined
|
||||
|
||||
@@ -6043,6 +6043,15 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_project_text_preview') {
|
||||
return {
|
||||
path: 'assets/ui-design.json',
|
||||
mediaType: 'application/json',
|
||||
byteLen: 2,
|
||||
content: '{}',
|
||||
uiDesignAssetId: 'ui-design-resource',
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
canDismissResourceCanvasQuickEdit,
|
||||
isResourceCanvasHostOverlayOpen,
|
||||
isResourceCanvasInteractionTarget,
|
||||
isResourceCanvasPanTarget,
|
||||
isResourceCanvasWheelOverlayTarget,
|
||||
resolveResourceCanvasFloatingPanelDismissOpen,
|
||||
resolveResourceCanvasFloatingPanelOpen,
|
||||
@@ -41,6 +42,38 @@ const RESOURCE_FOCUS_SOURCE_LAYER: CanvasLayer = {
|
||||
};
|
||||
|
||||
describe('resourceCanvasFocusModel', () => {
|
||||
test('右键抓手允许卡面和选中按钮,不抢媒体控件、编辑器及浮层', () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="blank"></div>
|
||||
<div class="game-resource-card" id="card">
|
||||
<button class="game-resource-card-select" id="select"></button>
|
||||
<button class="game-resource-card-media-control" id="play"></button>
|
||||
<video id="video"></video><input id="input" />
|
||||
</div>
|
||||
<div contenteditable="true" id="editor"></div>
|
||||
<div class="game-resource-filter-panel"><span id="filter">筛选</span></div>
|
||||
<div class="image-canvas-editor__floating-toolbar"><span id="toolbar">操作</span></div>
|
||||
<div class="game-resource-book-scene-titlebar" id="title"></div>
|
||||
`;
|
||||
for (const id of ['blank', 'card', 'select']) {
|
||||
expect(isResourceCanvasPanTarget(document.getElementById(id))).toBe(true);
|
||||
}
|
||||
for (const id of [
|
||||
'play',
|
||||
'video',
|
||||
'input',
|
||||
'editor',
|
||||
'filter',
|
||||
'toolbar',
|
||||
'title',
|
||||
]) {
|
||||
expect(isResourceCanvasPanTarget(document.getElementById(id))).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
expect(isResourceCanvasPanTarget(null)).toBe(false);
|
||||
});
|
||||
|
||||
test('点在资源卡、交互控件与画布浮层里时不清画布焦点', () => {
|
||||
document.body.innerHTML = `
|
||||
<div class="game-resource-canvas">
|
||||
|
||||
@@ -340,6 +340,323 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function mountPointerWorkbench(
|
||||
target: 'main' | 'character' | 'all' = 'character',
|
||||
) {
|
||||
const projectId = 'pointer-workbench';
|
||||
const projectPath = '/tmp/pointer-workbench';
|
||||
const tauri = installLayoutTauri({
|
||||
projectIdsByPath: { [projectPath]: projectId },
|
||||
});
|
||||
render(
|
||||
<LayoutWorkbench
|
||||
projects={[
|
||||
{
|
||||
projectId,
|
||||
projectPath,
|
||||
assets: [
|
||||
pngAsset('pointer-a', 'a.png'),
|
||||
pngAsset('pointer-b', 'b.png'),
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
await settleFocusChain();
|
||||
const manager = document.querySelector<HTMLElement>(
|
||||
'.game-resource-book-manager',
|
||||
)!;
|
||||
vi.spyOn(manager, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 800,
|
||||
bottom: 600,
|
||||
width: 800,
|
||||
height: 600,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
act(() => window.dispatchEvent(new Event('resize')));
|
||||
if (target !== 'main') {
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: target === 'all' ? '打开所有资源' : '打开角色与对象',
|
||||
}),
|
||||
);
|
||||
}
|
||||
await settleFocusChain();
|
||||
const surface = manager.querySelector<HTMLElement>(
|
||||
target === 'main'
|
||||
? '.game-resource-book-main'
|
||||
: '.game-resource-book-scene',
|
||||
)!;
|
||||
const capture = new Set<number>();
|
||||
Object.defineProperties(surface, {
|
||||
setPointerCapture: {
|
||||
configurable: true,
|
||||
value: vi.fn((id: number) => capture.add(id)),
|
||||
},
|
||||
hasPointerCapture: {
|
||||
configurable: true,
|
||||
value: (id: number) => capture.has(id),
|
||||
},
|
||||
releasePointerCapture: {
|
||||
configurable: true,
|
||||
value: vi.fn((id: number) => capture.delete(id)),
|
||||
},
|
||||
});
|
||||
const world = manager.querySelector<HTMLElement>(
|
||||
'.game-resource-book-scene-world',
|
||||
)!;
|
||||
const viewport = () =>
|
||||
Array.from(world.style.transform.matchAll(/-?\d+(?:\.\d+)?/g), (m) =>
|
||||
Number(m[0]),
|
||||
);
|
||||
return { manager, surface, world, viewport, tauri };
|
||||
}
|
||||
|
||||
describe('资源画布指针与运行提示', () => {
|
||||
it('资源卡左键拖动仍提交手动坐标,不平移视口', async () => {
|
||||
const { manager, viewport, tauri } = await mountPointerWorkbench();
|
||||
const card = manager.querySelector<HTMLElement>(
|
||||
'.is-expanded .game-resource-card[data-resource-card-id="asset:pointer-a"]',
|
||||
)!;
|
||||
const before = viewport();
|
||||
const writes = tauri.layoutWrites.length;
|
||||
fireEvent.pointerDown(card, {
|
||||
pointerId: 30,
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
fireEvent.pointerMove(card, {
|
||||
pointerId: 30,
|
||||
buttons: 1,
|
||||
clientX: 180,
|
||||
clientY: 140,
|
||||
});
|
||||
fireEvent.pointerUp(card, {
|
||||
pointerId: 30,
|
||||
button: 0,
|
||||
clientX: 180,
|
||||
clientY: 140,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(tauri.layoutWrites.length).toBeGreaterThan(writes),
|
||||
);
|
||||
expect(tauri.layoutWrites.at(-1)?.positions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resourceId: 'asset:pointer-a',
|
||||
manuallyPlaced: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(viewport()).toEqual(before);
|
||||
expect(
|
||||
document.querySelector('.genarrative-image-canvas__selection-overlay'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('资源选中不移除运行不可用提示或改变运行能力', async () => {
|
||||
const { manager } = await mountPointerWorkbench();
|
||||
const hint = '首个可运行原型尚未完成,运行视图暂不可用';
|
||||
expect(screen.getByText(hint)).not.toBeNull();
|
||||
fireEvent.click(
|
||||
manager.querySelector<HTMLElement>(
|
||||
'.is-expanded [data-resource-id="asset:pointer-a"]',
|
||||
)!,
|
||||
);
|
||||
expect(screen.getByText(hint)).not.toBeNull();
|
||||
const run = screen.getByRole('tab', { name: '运行' });
|
||||
expect(run.getAttribute('data-unavailable')).toBe('true');
|
||||
expect(run.getAttribute('aria-describedby')).toBe('run-unavailable-hint');
|
||||
fireEvent.click(run);
|
||||
expect(run.getAttribute('aria-selected')).toBe('false');
|
||||
});
|
||||
|
||||
it.each(['main', 'character', 'all'] as const)(
|
||||
'%s 的空白处支持右键平移且不写资源布局',
|
||||
async (target) => {
|
||||
const { surface, viewport, tauri } = await mountPointerWorkbench(target);
|
||||
const before = viewport();
|
||||
const writes = tauri.layoutWrites.length;
|
||||
fireEvent.pointerDown(surface, {
|
||||
pointerId: 31,
|
||||
button: 2,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
fireEvent.pointerMove(surface, {
|
||||
pointerId: 31,
|
||||
buttons: 2,
|
||||
clientX: 170,
|
||||
clientY: 140,
|
||||
});
|
||||
expect(viewport()).toEqual([before[0]! + 70, before[1]! + 40, before[2]]);
|
||||
expect(fireEvent.contextMenu(surface, { button: 2 })).toBe(false);
|
||||
fireEvent.pointerUp(surface, {
|
||||
pointerId: 31,
|
||||
button: 2,
|
||||
clientX: 170,
|
||||
clientY: 140,
|
||||
});
|
||||
expect(
|
||||
document.querySelector('.genarrative-image-canvas__selection-overlay'),
|
||||
).toBeNull();
|
||||
expect(tauri.layoutWrites).toHaveLength(writes);
|
||||
},
|
||||
);
|
||||
|
||||
it('资源卡右键平移保留选中,左键空白拖动仍框选', async () => {
|
||||
const { manager, surface, viewport, tauri } = await mountPointerWorkbench();
|
||||
const card = manager.querySelector<HTMLElement>(
|
||||
'.is-expanded [data-resource-id="asset:pointer-a"]',
|
||||
)!;
|
||||
fireEvent.click(card);
|
||||
const before = viewport();
|
||||
const writes = tauri.layoutWrites.length;
|
||||
fireEvent.pointerDown(card, {
|
||||
pointerId: 32,
|
||||
button: 2,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
fireEvent.pointerMove(surface, {
|
||||
pointerId: 32,
|
||||
buttons: 2,
|
||||
clientX: 150,
|
||||
clientY: 160,
|
||||
});
|
||||
fireEvent.pointerUp(surface, {
|
||||
pointerId: 32,
|
||||
button: 2,
|
||||
clientX: 150,
|
||||
clientY: 160,
|
||||
});
|
||||
expect(viewport()).toEqual([before[0]! + 50, before[1]! + 60, before[2]]);
|
||||
expect(card.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(tauri.layoutWrites).toHaveLength(writes);
|
||||
const panned = viewport();
|
||||
fireEvent.pointerDown(surface, {
|
||||
pointerId: 33,
|
||||
button: 0,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
});
|
||||
fireEvent.pointerMove(surface, {
|
||||
pointerId: 33,
|
||||
buttons: 1,
|
||||
clientX: 780,
|
||||
clientY: 580,
|
||||
});
|
||||
expect(
|
||||
document.querySelector('.genarrative-image-canvas__selection-overlay'),
|
||||
).not.toBeNull();
|
||||
expect(selectedResourceIdsInDom()).toContain('asset:pointer-a');
|
||||
expect(viewport()).toEqual(panned);
|
||||
fireEvent.pointerUp(surface, { pointerId: 33, button: 0 });
|
||||
expect(
|
||||
document.querySelector('.genarrative-image-canvas__selection-overlay'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['cancel', 'capture', 'blur'] as const)(
|
||||
'%s 后右键平移不会继续跟随指针',
|
||||
async (reason) => {
|
||||
const { surface, viewport } = await mountPointerWorkbench();
|
||||
const before = viewport();
|
||||
fireEvent.pointerDown(surface, {
|
||||
pointerId: 34,
|
||||
button: 2,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
fireEvent.pointerMove(surface, {
|
||||
pointerId: 34,
|
||||
buttons: 2,
|
||||
clientX: 110,
|
||||
clientY: 120,
|
||||
});
|
||||
const moved = viewport();
|
||||
expect(moved).toEqual([before[0]! + 10, before[1]! + 20, before[2]]);
|
||||
if (reason === 'cancel')
|
||||
fireEvent.pointerCancel(surface, { pointerId: 34 });
|
||||
else if (reason === 'capture')
|
||||
fireEvent.lostPointerCapture(surface, { pointerId: 34 });
|
||||
else act(() => window.dispatchEvent(new Event('blur')));
|
||||
fireEvent.pointerMove(surface, {
|
||||
pointerId: 34,
|
||||
clientX: 300,
|
||||
clientY: 300,
|
||||
});
|
||||
expect(viewport()).toEqual(moved);
|
||||
},
|
||||
);
|
||||
|
||||
it('总览失焦终止右键平移,子画布切换释放实际捕获节点', async () => {
|
||||
const { surface, viewport } = await mountPointerWorkbench('main');
|
||||
const before = viewport();
|
||||
fireEvent.pointerDown(surface, {
|
||||
pointerId: 36,
|
||||
button: 2,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
});
|
||||
fireEvent.pointerMove(surface, {
|
||||
pointerId: 36,
|
||||
buttons: 2,
|
||||
clientX: 40,
|
||||
clientY: 40,
|
||||
});
|
||||
const moved = viewport();
|
||||
expect(moved).toEqual([before[0]! + 30, before[1]! + 30, before[2]]);
|
||||
act(() => window.dispatchEvent(new Event('blur')));
|
||||
expect(surface.releasePointerCapture).toHaveBeenCalledWith(36);
|
||||
fireEvent.pointerMove(surface, {
|
||||
pointerId: 36,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
expect(viewport()).toEqual(moved);
|
||||
fireEvent.pointerDown(surface, {
|
||||
pointerId: 37,
|
||||
button: 2,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' }));
|
||||
expect(surface.releasePointerCapture).toHaveBeenCalledWith(37);
|
||||
});
|
||||
|
||||
it('控件右键不被画布接管,初次打开不整理也能连续双指平移', async () => {
|
||||
const { manager, surface, viewport } = await mountPointerWorkbench();
|
||||
const zoom = screen.getByRole('button', { name: '放大画布' });
|
||||
const before = viewport();
|
||||
fireEvent.pointerDown(zoom, {
|
||||
pointerId: 35,
|
||||
button: 2,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
fireEvent.pointerMove(surface, {
|
||||
pointerId: 35,
|
||||
buttons: 2,
|
||||
clientX: 200,
|
||||
clientY: 200,
|
||||
});
|
||||
expect(viewport()).toEqual(before);
|
||||
expect(fireEvent.contextMenu(zoom, { button: 2 })).toBe(true);
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
fireEvent.wheel(manager, { deltaX: 3, deltaY: 6 });
|
||||
}
|
||||
});
|
||||
expect(viewport()).toEqual([before[0]! - 300, before[1]! - 600, before[2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('资源画布手动重排口径', () => {
|
||||
it('hook:rederiveNow 按 rederive 策略重算自动坐标并写回一次', async () => {
|
||||
const projectId = 'manual-rederive-project';
|
||||
|
||||
@@ -206,7 +206,7 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => {
|
||||
// `artKind`(其中含 `ui`)而被判成 art,于是角标显示「图片」,而预览调度按 art 走
|
||||
// 图像分支、又因 mediaType 不是图像而兜底成 placeholder —— 卡片永不发起读取,
|
||||
// 表现为「标着图片却只有占位图标」。类型判定改为 mediaType/扩展名优先、kind 只做
|
||||
// 兜底后,它们正确落文档分支,卡面渲染 JSON 文本摘要。
|
||||
// 兜底后,它们进入文本读取通道;卡面再消费原生识别结果显示 UI 设计或 JSON。
|
||||
expect(byPreviewKind).toEqual({
|
||||
'raster-image': 52,
|
||||
document: 8,
|
||||
|
||||
@@ -40,19 +40,21 @@ describe('resourceDocumentPreviewMarkdown', () => {
|
||||
['game/main.ts', 'typescript'],
|
||||
['game/main.js', 'javascript'],
|
||||
['game/main.py', 'python'],
|
||||
['assets/data.json', 'json'],
|
||||
['assets/DESIGN.JSON', 'json'],
|
||||
])('%s 包为 %s 代码块', (path, language) => {
|
||||
expect(
|
||||
resourceDocumentPreviewMarkdown(resource(path), ' source\n\n\n'),
|
||||
).toBe(`\`\`\`${language}\n source\n\n\n\`\`\``);
|
||||
});
|
||||
|
||||
it('JSON 规格按文档原文预览,不包成代码块', () => {
|
||||
it('JSON 规格按原文代码块预览,不把字段值当 Markdown', () => {
|
||||
expect(
|
||||
resourceDocumentPreviewMarkdown(
|
||||
resource('assets/data.json'),
|
||||
' source\n\n\n',
|
||||
'{ "text": "# 不应成为标题" }\n',
|
||||
),
|
||||
).toBe(' source\n\n\n');
|
||||
).toBe('```json\n{ "text": "# 不应成为标题" }\n```');
|
||||
});
|
||||
|
||||
it('正文包含 Markdown 围栏时不会逃出代码块', () => {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createGameCreationAppManifest,
|
||||
fireEvent,
|
||||
installResizeObserverStub,
|
||||
ProjectDevelopmentView,
|
||||
React,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from './appSurface/harness';
|
||||
|
||||
vi.mock('@tauri-apps/api/core', async () => ({
|
||||
...(await vi.importActual<typeof import('@tauri-apps/api/core')>(
|
||||
'@tauri-apps/api/core',
|
||||
)),
|
||||
invoke: (command: string, args?: Record<string, unknown>) =>
|
||||
window.__TAURI__!.core.invoke(command, args),
|
||||
}));
|
||||
|
||||
const projectId = 'json-canvas';
|
||||
const projectPath = '/tmp/json-canvas';
|
||||
const emptyState = {
|
||||
ui_trees: [],
|
||||
ui_design_images: {},
|
||||
sprite_assets: {},
|
||||
font_assets: {},
|
||||
};
|
||||
|
||||
async function mountJsonCanvas(kind = 'ui', rejectPreview = false) {
|
||||
installResizeObserverStub();
|
||||
const manifest = createGameCreationAppManifest(projectId, 'JSON 资源测试');
|
||||
manifest.assets = [
|
||||
{ id: 'design', kind, localPath: 'ui/design.json' },
|
||||
{ id: 'ordinary', kind: 'UI', localPath: 'assets/ordinary.json' },
|
||||
{ id: 'spoof', kind: 'UI', localPath: 'ui/spoof.json' },
|
||||
].map((asset) => ({
|
||||
...asset,
|
||||
mediaType: 'application/json',
|
||||
category: 'document' as const,
|
||||
source: { kind: 'generated' as const },
|
||||
}));
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_resource_graph') {
|
||||
const ids = (args?.resources as Array<{ resourceId: string }>).map(
|
||||
(item) => item.resourceId,
|
||||
);
|
||||
return {
|
||||
resourceIds: ids,
|
||||
referenceEdges: [],
|
||||
taskFlows: [],
|
||||
producerAssignments: [],
|
||||
dependencyDepths: ids.map((resourceId) => ({
|
||||
resourceId,
|
||||
dependencyDepth: 0,
|
||||
})),
|
||||
connectionIndex: ids.map((resourceId) => ({
|
||||
resourceId,
|
||||
upstreamReferenceResourceIds: [],
|
||||
downstreamReferenceResourceIds: [],
|
||||
referenceEdgeIds: [],
|
||||
taskFlowIds: [],
|
||||
})),
|
||||
unresolvedReferenceResourceIds: [],
|
||||
cyclicResourceIds: [],
|
||||
cyclicTaskIds: [],
|
||||
producerMappingTruncated: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
command === 'read_local_project_resource_canvas_layout' ||
|
||||
command === 'update_local_project_resource_canvas_layout'
|
||||
) {
|
||||
const layout = {
|
||||
schemaVersion: 'game-creator-resource-layout.v1',
|
||||
projectId,
|
||||
mode: args?.mode,
|
||||
revision: Number(args?.expectedRevision ?? 0) + 1,
|
||||
positions: args?.positions ?? [],
|
||||
updatedAt: 1,
|
||||
};
|
||||
return command.startsWith('update_')
|
||||
? { status: 'updated', layout }
|
||||
: layout;
|
||||
}
|
||||
if (command === 'read_local_project_text_preview') {
|
||||
if (rejectPreview) throw new Error('文档读取失败');
|
||||
const path = args?.relativePath;
|
||||
return {
|
||||
path,
|
||||
mediaType: 'application/json',
|
||||
byteLen: 20,
|
||||
content:
|
||||
path === 'assets/ordinary.json'
|
||||
? '{"text":"# 不应当作标题"}'
|
||||
: JSON.stringify({
|
||||
schemaVersion: 'game-creator-ui-design-state.v1',
|
||||
state: emptyState,
|
||||
}),
|
||||
...(path === 'ui/design.json' ? { uiDesignAssetId: 'design' } : {}),
|
||||
};
|
||||
}
|
||||
if (command === 'load_ui_design_state')
|
||||
return { revision: 0, state: emptyState };
|
||||
if (
|
||||
command === 'list_pending_local_project_resource_edits' ||
|
||||
command === 'list_local_project_asset_generations'
|
||||
)
|
||||
return [];
|
||||
throw new Error(`unexpected command ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
|
||||
const onManifestChange = vi.fn();
|
||||
render(
|
||||
<ProjectDevelopmentView
|
||||
projectPath={projectPath}
|
||||
projectName={manifest.name}
|
||||
manifest={manifest}
|
||||
attachments={[]}
|
||||
recentRunStatus={null}
|
||||
recentRunStopReason={null}
|
||||
supervisor={<div>对话</div>}
|
||||
onManifestChange={onManifestChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '打开文档' }));
|
||||
const card = (id: string) =>
|
||||
document.querySelector<HTMLElement>(
|
||||
`.game-resource-book-scene-card.is-expanded [data-resource-card-id="asset:${id}"]`,
|
||||
)!;
|
||||
await waitFor(() =>
|
||||
expect(card('design')?.getAttribute('data-preview-status')).toBe(
|
||||
rejectPreview ? 'failed' : 'loaded',
|
||||
),
|
||||
);
|
||||
return { card, invoke, onManifestChange };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__TAURI__;
|
||||
});
|
||||
|
||||
describe('JSON 画布卡片与入口', () => {
|
||||
it.each(['ui', 'document'])(
|
||||
'合法 State 的 %s 登记显示 UI 设计,进入现有编辑器且不修改 manifest',
|
||||
async (kind) => {
|
||||
const { card, invoke, onManifestChange } = await mountJsonCanvas(kind);
|
||||
const design = card('design');
|
||||
expect(design.getAttribute('data-json-presentation')).toBe('ui-design');
|
||||
expect(within(design).getByText('UI 设计')).not.toBeNull();
|
||||
expect(within(design).getByText('UI 编辑器')).not.toBeNull();
|
||||
expect(design.textContent).not.toContain('schemaVersion');
|
||||
fireEvent.click(design.querySelector('button')!);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'UI 编辑器' }));
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith('load_ui_design_state', {
|
||||
input: {
|
||||
projectPath,
|
||||
expectedProjectId: projectId,
|
||||
assetId: 'design',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(onManifestChange).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('普通 JSON 和伪 UI 文本保持 JSON 展示,无编辑器入口,详情按代码渲染', async () => {
|
||||
const { card } = await mountJsonCanvas();
|
||||
for (const id of ['ordinary', 'spoof']) {
|
||||
const element = card(id);
|
||||
expect(element.getAttribute('data-json-presentation')).toBe('json');
|
||||
expect(
|
||||
element.querySelector('[data-resource-type="JSON"]'),
|
||||
).not.toBeNull();
|
||||
expect(element.textContent).not.toContain('schemaVersion');
|
||||
fireEvent.click(element.querySelector('button')!);
|
||||
expect(screen.queryByRole('button', { name: 'UI 编辑器' })).toBeNull();
|
||||
}
|
||||
fireEvent.click(card('ordinary').querySelector('button')!);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '预览' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '文档预览' });
|
||||
await waitFor(() =>
|
||||
expect(dialog.querySelector('pre code')?.textContent).toContain(
|
||||
'{"text":"# 不应当作标题"}',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
within(dialog).queryByRole('heading', { name: '不应当作标题' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('JSON 读取失败不授予编辑入口,仍可查看明确的读取错误', async () => {
|
||||
const { card } = await mountJsonCanvas('UI', true);
|
||||
fireEvent.click(card('design').querySelector('button')!);
|
||||
expect(screen.queryByRole('button', { name: 'UI 编辑器' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '预览' }));
|
||||
expect(await screen.findByRole('alert')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type ProjectResourceCardPreviewState,
|
||||
projectResourceJsonPresentation,
|
||||
} from '../src/view/project-development/resourceCardPreviewModel';
|
||||
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
|
||||
|
||||
const resource: ProjectResource = {
|
||||
id: 'asset:json',
|
||||
manifestAssetId: 'json',
|
||||
path: 'ui/design.json',
|
||||
label: '设计',
|
||||
mediaType: 'application/json',
|
||||
subtype: 'UI',
|
||||
category: 'document',
|
||||
sourceLabel: '',
|
||||
taskTitle: null,
|
||||
producerTaskId: null,
|
||||
externalResourceId: null,
|
||||
referenceResourceIds: [],
|
||||
dependencies: [],
|
||||
dependencyDepth: 0,
|
||||
};
|
||||
const verified: ProjectResourceCardPreviewState = {
|
||||
status: 'loaded',
|
||||
preview: {
|
||||
path: resource.path,
|
||||
mediaType: 'application/json',
|
||||
byteLen: 2,
|
||||
content: '{}',
|
||||
uiDesignAssetId: 'json',
|
||||
},
|
||||
};
|
||||
|
||||
describe('JSON 卡片呈现', () => {
|
||||
it.each(['UI', 'ui', 'ui-design', 'document'])(
|
||||
'合法识别不依赖 %s 标签',
|
||||
(subtype) => {
|
||||
expect(
|
||||
projectResourceJsonPresentation({ ...resource, subtype }, verified),
|
||||
).toBe('ui-design');
|
||||
expect(
|
||||
projectResourceJsonPresentation(
|
||||
{ ...resource, subtype },
|
||||
{
|
||||
...verified,
|
||||
preview: { ...verified.preview, uiDesignAssetId: undefined },
|
||||
},
|
||||
),
|
||||
).toBe('json');
|
||||
},
|
||||
);
|
||||
it('原生读取未完成、失败或身份不匹配时保持普通 JSON,不推断编辑能力', () => {
|
||||
for (const preview of [
|
||||
null,
|
||||
{ status: 'loading' },
|
||||
{ status: 'failed', error: '读取失败', retryable: true },
|
||||
] as const) {
|
||||
expect(projectResourceJsonPresentation(resource, preview)).toBe('json');
|
||||
}
|
||||
expect(
|
||||
projectResourceJsonPresentation(
|
||||
{ ...resource, manifestAssetId: 'other' },
|
||||
verified,
|
||||
),
|
||||
).toBe('json');
|
||||
expect(
|
||||
projectResourceJsonPresentation(
|
||||
{ ...resource, path: 'other.json' },
|
||||
verified,
|
||||
),
|
||||
).toBe('json');
|
||||
expect(
|
||||
projectResourceJsonPresentation(
|
||||
{ ...resource, manifestAssetId: null },
|
||||
verified,
|
||||
),
|
||||
).toBe('json');
|
||||
expect(
|
||||
projectResourceJsonPresentation(
|
||||
{ ...resource, path: 'image.png', mediaType: 'image/png' },
|
||||
verified,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -141,6 +141,60 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('useProjectResourceCardPreviews', () => {
|
||||
it('JSON 识别结果经过现有预取缓存保留,换项目后不复用旧编辑能力', async () => {
|
||||
const json = resource('design', {
|
||||
path: 'ui/design.json',
|
||||
subtype: 'ui',
|
||||
mediaType: 'application/json',
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (_command: string, args?: Record<string, unknown>) => ({
|
||||
path: json.path,
|
||||
mediaType: json.mediaType,
|
||||
byteLen: 2,
|
||||
content: '{}',
|
||||
...(args?.projectPath === '/tmp/first-project'
|
||||
? { uiDesignAssetId: 'design' }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const resources = [json];
|
||||
const canvasRef = { current: document.createElement('div') };
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectPath }) =>
|
||||
useProjectResourceCardPreviews({
|
||||
projectPath,
|
||||
projectId: projectPath,
|
||||
mode: 'dependency',
|
||||
resources,
|
||||
canvasRef,
|
||||
eagerPreviewLimit: 12,
|
||||
}),
|
||||
{ initialProps: { projectPath: '/tmp/first-project' } },
|
||||
);
|
||||
const identity = () => result.current.identityByResourceId.get(json.id)!;
|
||||
await waitFor(() =>
|
||||
expect(result.current.previews.get(identity())).toMatchObject({
|
||||
status: 'loaded',
|
||||
preview: { uiDesignAssetId: 'design' },
|
||||
}),
|
||||
);
|
||||
rerender({ projectPath: '/tmp/second-project' });
|
||||
await waitFor(() =>
|
||||
expect(result.current.previews.get(identity())?.status).toBe('loaded'),
|
||||
);
|
||||
const state = result.current.previews.get(identity());
|
||||
expect(
|
||||
state?.status === 'loaded' && state.preview.uiDesignAssetId,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'read_local_project_text_preview',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('代码卡不预取正文,显式详情复用文本预览队列与缓存', async () => {
|
||||
const code = resource('code', {
|
||||
path: 'game/main.ts',
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { WindowChrome } from '../src/components/WindowChrome';
|
||||
import {
|
||||
useWindowChrome,
|
||||
type WindowChromeActiveProjectRuns,
|
||||
WindowChromeContext,
|
||||
} from '../src/components/windowChromeContext';
|
||||
import { WorkspaceLauncherShell } from '../src/features/app-shell/WorkspaceLauncher';
|
||||
import {
|
||||
act,
|
||||
expect,
|
||||
it,
|
||||
React,
|
||||
render,
|
||||
testAuthUser,
|
||||
} from './appSurface/harness';
|
||||
|
||||
const homeProjectOverride = vi.hoisted(() => ({
|
||||
openProject: null as
|
||||
| null
|
||||
| ((path: string, mode: 'open' | 'create') => Promise<void>),
|
||||
}));
|
||||
|
||||
vi.mock('../src/features/app-shell/useHomeProjectCreation', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../src/features/app-shell/useHomeProjectCreation')
|
||||
>('../src/features/app-shell/useHomeProjectCreation');
|
||||
return {
|
||||
...actual,
|
||||
useHomeProjectCreation(
|
||||
...args: Parameters<typeof actual.useHomeProjectCreation>
|
||||
) {
|
||||
const result = actual.useHomeProjectCreation(...args);
|
||||
return homeProjectOverride.openProject
|
||||
? { ...result, openProject: homeProjectOverride.openProject }
|
||||
: result;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('真实窗口与工作台状态同步收敛,回调读取最新处理器且卸载才清理', async () => {
|
||||
const publications: WindowChromeActiveProjectRuns[] = [];
|
||||
let cleanups = 0;
|
||||
// 仍经过真实 WindowChrome 的 setState/Context;上限只防止回归时测试无限循环。
|
||||
function BoundedWindowBridge({ children }: { children: ReactNode }) {
|
||||
const chrome = useWindowChrome();
|
||||
const { setActiveProjectRuns } = chrome;
|
||||
const publish = useCallback(
|
||||
(next: WindowChromeActiveProjectRuns | null) => {
|
||||
if (next) publications.push(next);
|
||||
else cleanups += 1;
|
||||
if (publications.length < 12) setActiveProjectRuns(next);
|
||||
},
|
||||
[setActiveProjectRuns],
|
||||
);
|
||||
return (
|
||||
<WindowChromeContext.Provider
|
||||
value={{ ...chrome, setActiveProjectRuns: publish }}
|
||||
>
|
||||
{children}
|
||||
</WindowChromeContext.Provider>
|
||||
);
|
||||
}
|
||||
const supervisor = () => null;
|
||||
const view = (displayName = '测试用户') => (
|
||||
<WindowChrome>
|
||||
<BoundedWindowBridge>
|
||||
<WorkspaceLauncherShell
|
||||
currentUser={{ ...testAuthUser, displayName }}
|
||||
onLogout={() => undefined}
|
||||
initialView="projects"
|
||||
ProjectSupervisor={supervisor}
|
||||
/>
|
||||
</BoundedWindowBridge>
|
||||
</WindowChrome>
|
||||
);
|
||||
delete window.__TAURI__;
|
||||
const rendered = render(view());
|
||||
try {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
// 无原生 invoke 时 active-turn Hook 会把初始快照归一为空数组一次。
|
||||
expect(publications).toHaveLength(2);
|
||||
expect(cleanups).toBe(0);
|
||||
expect(new Set(publications.map((item) => item.onOpenProject)).size).toBe(
|
||||
1,
|
||||
);
|
||||
const openProject = publications[0]!.onOpenProject!;
|
||||
const latestOpen = vi.fn(async () => undefined);
|
||||
homeProjectOverride.openProject = latestOpen;
|
||||
rendered.rerender(view('更改显示名'));
|
||||
expect(publications).toHaveLength(2);
|
||||
act(() => openProject('/tmp/window-latest-project'));
|
||||
expect(latestOpen).toHaveBeenCalledWith(
|
||||
'/tmp/window-latest-project',
|
||||
'open',
|
||||
);
|
||||
expect(publications).toHaveLength(2);
|
||||
expect(cleanups).toBe(0);
|
||||
rendered.unmount();
|
||||
expect(cleanups).toBe(1);
|
||||
} finally {
|
||||
homeProjectOverride.openProject = null;
|
||||
rendered.unmount();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
# AGC 画布交互稳定性修复实施计划
|
||||
|
||||
- Date: 2026-09-16
|
||||
- Status: awaiting-runtime-acceptance
|
||||
- Milestone: [画布交互稳定性修复](./【里程碑】AGC画布交互稳定性修复-2026-09-16.md)
|
||||
|
||||
## 修改顺序与边界
|
||||
|
||||
1. 将临时诊断收敛成正式回归测试,覆盖窗口 Context 反馈、提示条件和鼠标/触摸板事件。
|
||||
2. 稳定工作台打开项目的转发回调,保持最新处理器语义,不改项目加载逻辑。
|
||||
3. 解耦运行提示与选择;在原有画布事件链加入右键平移、菜单边界和中断清理,不另建控制器。
|
||||
4. 执行定向验证并检查首次加载、平移和原有框选/卡片拖动回归。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向 Vitest:窗口工作台、画布交互、布局与原有导航用例。
|
||||
- AGC `tsc --noEmit`;`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。
|
||||
- 真实客户端首次进入和触摸板操作无法以 jsdom 代替;未实测时保持待验收。
|
||||
|
||||
## 风险与停止条件
|
||||
|
||||
窗口反馈测试必须有更新次数上限,避免未修复代码让测试失控。右键仅接管画布背景/卡片,不抢输入控件与浮层。新增问题只有影响本次验收才扩大范围;不根据猜测修改 B01/B02 的布局与动画。
|
||||
|
||||
回滚仅限本批局部补丁;不重置工作树,不覆盖其它修改。完成自动化验证后停在真实客户端验收,不推进额外功能。
|
||||
|
||||
## 问题状态
|
||||
|
||||
| 编号 | 问题 | 状态与证据 |
|
||||
| --- | --- | --- |
|
||||
| B01 | 刷新后首次进入画布元素抖动 | 已优化;用户在本轮反馈未再复现,按用户要求更新状态。不宣称所有布局/动画原因均已排除。 |
|
||||
| B02 | 初次进入双指平移无效,整理后恢复 | 已优化;用户在本轮反馈未再复现,按用户要求更新状态。隔离组件连续平移通过。 |
|
||||
| B03 | 快速平移触发更新深度错误 | 已修复已确认的窗口 Context 反馈循环,回归验证收敛;真实操作继续观察。 |
|
||||
| B04 | 资源选中后运行不可用提示消失 | 已修复,提示与选择解耦,自动化验证通过。 |
|
||||
| B05 | 对话记录偶发丢失 | 已定位历史分页卡点,尚未修改。只读复验用户提供的历史:558 条合法原始记录中有 44 条聊天消息;首屏原始 20 条只投影出一条助手消息,下一页原始 20 条没有聊天消息,按消息计算的游标不推进。已存用户提问和最终回答因此无法继续翻出;不能凭该文件排除其它未落盘记录。 |
|
||||
| B06 | JSON 文档未正确识别展示 | 已按用户确认完成本地修复:合法 UI State 由原生完整校验,卡片显示 UI 设计并进入现有编辑器;普通 JSON 显示 JSON 并可代码预览。自动化验证通过,待重建原生客户端验收;详见 JSON 语义识别实施计划。 |
|
||||
| C01 | 右键平移,保留左键框选 | 已实现,卡片左键拖动、框选、指针取消/失焦/捕获丢失及控件边界测试通过。 |
|
||||
|
||||
## 已取得证据与剩余门禁
|
||||
|
||||
- 修复前新增回归测试能检出外壳重复发布、运行提示消失和右键无效;修复后窗口/画布定向测试通过,现有导航、框选、指针点击/取消、UI 编辑器返回平移和素材定位用例通过。
|
||||
- AGC TypeScript、修改文件 ESLint、编码、文档索引及差异空白检查通过。
|
||||
- 测试仍有既有 React 列表 key、旧用例 act/IPC 桩告警,未作为本批功能修复扩大范围。
|
||||
- 用户反馈 B01/B02 本轮未再复现,记为已优化;右键手感与其它真实客户端细节继续观察。对话历史分页尚未修复;JSON 双路径已本地修复并通过自动化验证,待真实客户端验收。本计划保持开放。
|
||||
@@ -0,0 +1,27 @@
|
||||
# AGC 资源 JSON 语义识别实施计划
|
||||
|
||||
- Date: 2026-09-16
|
||||
- Status: awaiting-runtime-acceptance
|
||||
- Milestone: [AGC 资源 JSON 语义识别](./【里程碑】AGC资源JSON语义识别-2026-09-16.md)
|
||||
|
||||
## 实施边界
|
||||
|
||||
1. 原生 UI 持久化模块抽取可复用的内容解析与只读识别;文本预览返回可选的已验证 UI 资产身份。
|
||||
2. 保留新设计初始化的严格 UI 资产门禁;已有 State 的加载/保存/生成按登记资源和真实文档校验,不依赖标签精确大小写。
|
||||
3. 前端预览缓存透传识别结果;工作台卡片与编辑器入口消费同一结果,普通 JSON 详情使用代码块。
|
||||
4. 单元、组件与原生测试覆盖合法/普通/损坏/跨身份/未登记和保存边界;保持先前画布修复。
|
||||
|
||||
## 验证与停止条件
|
||||
|
||||
- 定向 Vitest、Tauri persistence/resource preview 定向 Rust 测试、AGC TypeScript、修改文件 ESLint、编码、文档索引及 `git diff --check`。
|
||||
- 不读取或修改用户项目原文件,不将日志或真实对话作为仓库测试夹具。
|
||||
- 原生构建/真实客户端受环境限制时记录实际证据,不能以 TS 测试代替原生验证。
|
||||
- 完成上述范围后停止;B05 已有分页卡点证据,本轮不顺带修改历史合同。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- 92 个前端定向测试通过,覆盖原生结果驱动的卡片/编辑器路由、普通 JSON 代码预览、伪 UI 文本拒绝、读取失败、缓存切项目,以及原有画布导航与指针回归。
|
||||
- 14 个 UI 持久化原生测试通过,包含有效 State 对多种登记标签的识别/加载/保存、未知 schema/字段与坏结构拒绝、项目/资产身份、普通 JSON 防覆盖及原有恢复/CAS 边界。
|
||||
- 原生测试在 macOS 默认 `/var` 临时路径触发既有拒绝符号链接门禁;改用真实 `/private/tmp` 后通过,未放宽产品路径安全校验。
|
||||
- AGC TypeScript、修改文件 ESLint 已通过。测试仍有既有 React key/act 告警及 Rust 未使用代码警告,不影响本批断言。
|
||||
- 真实客户端的图片/UI State 体验仍待验收。本次包含 Rust 预览字段,必须重新构建并启动原生端,不能仅刷新前端就认为识别结果已更新。
|
||||
@@ -0,0 +1,26 @@
|
||||
# AGC 画布交互稳定性修复
|
||||
|
||||
- Version: 1
|
||||
- Status: reviewed
|
||||
- Date: 2026-09-16
|
||||
- Parent Spec: [AGC 实施计划:资源画布交互与工作台状态同步](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)
|
||||
|
||||
## 目标与范围
|
||||
|
||||
完成工作台标题栏状态更新循环、运行提示随选择消失、右键平移三项本地修复,保持左键框选与卡片拖动。不修改布局算法、持久化和后端;不开远程 Issue/PR、不推送。
|
||||
|
||||
## 评审
|
||||
|
||||
依据已完成的源码追踪、客户端错误日志及有界复现自检:状态归属仍在窗口与工作台原有边界内;交互修改只影响画布手势;没有数据迁移、权限或 API 变化。用户已确认右键平移及保留左键框选,并授权先尝试本地修复。真实客户端首次进入抖动与触摸板平移仍需另行验收。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 工作台发布窗口状态后收敛;重复渲染不持续更新或清理,打开项目回调使用最新处理器,卸载清理有效。
|
||||
2. 选中资源后运行不可用提示仍在,运行能力不变。
|
||||
3. 子画布空白处及卡片右键平移不改变选择或坐标;左键框选与卡片拖动保留;总览支持右键平移。
|
||||
4. 指针取消、失去捕获、窗口失焦终止平移;控件与浮层保留原有交互。
|
||||
5. 连续平移测试、定向测试、类型检查、编码与文档门禁完成并记录限制;B01/B02 仅在真实复测后决定关闭。
|
||||
|
||||
## 依赖
|
||||
|
||||
当前源码与已安装测试依赖;真实 AGC 客户端验收环境。没有外部写操作依赖。
|
||||
@@ -0,0 +1,26 @@
|
||||
# AGC 资源 JSON 语义识别
|
||||
|
||||
- Version: 1
|
||||
- Status: reviewed
|
||||
- Date: 2026-09-16
|
||||
- Parent Spec: [AGC 实施计划:文档与代码素材预览](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)
|
||||
|
||||
## 目标与边界
|
||||
|
||||
按用户确认同时支持普通 JSON 与 UI 设计 JSON:前者显示 JSON 并可代码预览,后者显示 UI 设计并进入原有编辑器。原生侧复用现有文档校验,前端不推断正式状态。不修改 manifest 分类,不迁移用户文件,不改历史分页,不做远程写入。
|
||||
|
||||
## 评审
|
||||
|
||||
已核对原生受控文本预览、UI State 持久化合同和工作台卡片/编辑器路由。复用完整读取结果识别,未知 schema/坏结构/身份不符均不授予编辑入口;已有 UI 资产的大小写标签不应阻断真实合法 State。新增可选预览字段只承载原生识别结果;不引入新的状态文件或平行编辑器。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 普通 JSON 显示 JSON,不渲染卡面原文,不出现 UI 编辑器入口,显式预览为 JSON 代码块。
|
||||
2. 完整合法且身份匹配的 UI State 显示 UI 设计并进入现有编辑器;大小写标签或文档标签不影响内容识别。
|
||||
3. 损坏 JSON、伪 schema、未知字段、坏 State、跨项目/资产、未登记文件不能获得 UI 编辑入口;原文查看或读取错误仍可见。
|
||||
4. 识别不产生写入;普通 JSON 不能通过编辑器保存或初始化被覆盖。有效已有设计仍支持原有 CAS 保存。
|
||||
5. 预览缓存能传递原生结果并随资源身份失效;原有卡片、框选/平移和图片预览回归通过。
|
||||
|
||||
## 依赖
|
||||
|
||||
本地前端与 Tauri 源码、现有测试依赖;真实客户端体验单独验收。
|
||||
@@ -1,5 +1,13 @@
|
||||
# 踩坑与排障记录
|
||||
|
||||
## JSON 卡片显示与 UI 编辑能力必须同源
|
||||
|
||||
JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡片、缩略图及编辑器入口共同消费受控文本预览的 `uiDesignAssetId`;只有原生复用 UI 持久化合同校验 schema、完整 State 和项目/资产身份后才设置它。普通 JSON 保留 JSON 代码预览,不按 `kind: UI/ui` 或 schema 字符串片段猜测编辑能力。已有合法 UI State 的加载/保存不依赖 kind 精确大小写,但新建初始化仍保留正式 UI 资产门禁;缓存与项目切换须保留现有身份隔离。
|
||||
|
||||
## 窗口 Context 发布不得依赖每次渲染新建的业务回调
|
||||
|
||||
工作台向窗口标题栏发布运行项目时,若 effect 依赖普通函数派生的回调,发布 Context 会重新渲染工作台,进而再次发布并清理,形成更新深度循环。转发入口须稳定,并在提交阶段更新实际处理器引用;发布数据变化与卸载清理分开。回归测试必须组合真实窗口 Provider 和工作台消费者,只有独立画布测试无法覆盖这条反馈链;回归时用有界发布次数阻止测试失控。画布快速操作时暴露的更新深度错误,也须检查外层状态同步,不能直接归因于滚轮频率。
|
||||
|
||||
## Windows 已登记生图资产未刷新
|
||||
|
||||
Direct 工具桥会 canonicalize 项目根,事件中的路径可能带 `\\?\` / `\\?\UNC\`,而前端项目路径仍是普通盘符或 UNC。失效监听不能直接比较原始字符串;识别为同一项目后,用当前项目路径重读 manifest,保留项目切换与 revision 门禁。普通 `agc_generate_image` 成功提交也必须发出失效通知,不能依赖整轮 Agent 结束。回归需覆盖两种 Windows 前缀、其它项目事件拒收,以及 Agent 尚未结束和后续失败时已登记图片卡片仍可见。
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# AI 游戏创作智能体 App 实施计划
|
||||
|
||||
## 资源画布交互与工作台状态同步
|
||||
|
||||
- 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。
|
||||
- 资源子画布(含「所有资源」)保留空白处左键框选、资源卡左键选中/拖动、触摸板双指平移及捏合缩放;右键按住空白处或资源卡拖动时平移画布,不改变资源选择与布局。中键和空格抓手继续可用。总览保留既有左键平移,并支持右键平移。
|
||||
- 画布接管的右键手势不弹出原生菜单;输入框、媒体操作、工具条和独立浮层不被画布抢占。指针取消、捕获丢失或窗口失焦后终止平移,不能继续跟随指针。
|
||||
- 运行不可用提示只取决于运行能力与 UI 编辑器状态,不因资源选中、取消选中或框选而消失,且不改变运行入口的真实可用性。
|
||||
- 本次边界不包含布局算法、持久化坐标、预览读取预算或后端契约调整。首次进入抖动与平移异常须在更新循环消除后单独实测,不能仅凭状态循环修复宣称已解决。
|
||||
- 验收包含真实窗口 Context 与工作台的状态同步回归、运行提示与选中并存、左右键分流、平移中断及连续滚轮事件;真实客户端首次进入与触摸板手感为独立人工验收项。
|
||||
|
||||
## 资源卡选中工具栏与导出
|
||||
|
||||
- 共享选中工具栏按实际显示的快速编辑、编辑动作、改造、导出与宿主动作组生成分隔线;空组不产生分隔线,不依赖宿主 CSS 隐藏重复线。
|
||||
@@ -8,6 +17,10 @@
|
||||
|
||||
## 文档与代码素材预览
|
||||
|
||||
- JSON 资源的卡片按内容语义分流:普通 JSON 显示 JSON 图标/标签,详情按 JSON 代码块显示;UI 设计 JSON 显示 UI 设计标识并提供现有 UI 编辑器入口,不再把 State 原文铺在卡面上。功能分类、manifest kind 和资产身份不因识别而改写。
|
||||
- UI 识别由原生文本预览在已登记、受控、完整读取的同一份 UTF-8 内容上完成,复用编辑器的 `game-creator-ui-design-state.v1` 契约解析、canonical 校验、revision/State 校验及项目/资产身份校验。前端只消费识别结果,不按文件名、kind 大小写或正文片段自行认定 UI;普通、损坏、未知 schema、跨项目或跨资产 JSON 不获得 UI 编辑能力。
|
||||
- JSON 为完成内容识别继续走现有受限预取队列、并发/容量和 2 MiB 原生文本读取上限。识别失败不写文件、不生成空 State、不做格式迁移;能读取的原文仍可按 JSON 查看,读取失败仍展示原有错误。非 JSON 代码卡继续仅按用户详情请求读取。
|
||||
- 编辑器加载/保存/代码生成复核当前项目的已登记 JSON 资产及完整文档,不以 kind 必须精确等于 `UI` 阻断合法已有设计;创建新 UI 状态仍要求正式 UI 资源,不能借普通 JSON 预览初始化或覆盖文件。现有 State 保存锁、CAS、revision 与恢复边界不变。
|
||||
- 文档与代码素材选中工具栏提供「预览」,打开独立、可滚动的只读弹窗;关闭、切换素材或项目后不残留旧内容。加载中、空文件与读取失败分别呈现,允许重试可重试的错误。
|
||||
- 复用资源预览队列、身份缓存、项目 scope、失效与权限校验,继续调用 `read_local_project_text_preview`。代码卡不做可见性预取,仅用户显式打开详情时读取;不新增 IPC,不扩大可读取文件范围,不增加编辑/保存能力。
|
||||
- 文档正文统一使用现有 Markdown 渲染器;代码文件以按扩展名标注语言的 Markdown 围栏代码块呈现。围栏必须长于正文内的反引号串,正文空行与缩进保持原样,不把源码当 Markdown 正文或 HTML 执行。
|
||||
|
||||
Reference in New Issue
Block a user