修复AGC画布交互并识别UI设计JSON

稳定工作台与窗口标题栏状态同步,消除重复更新循环
支持画布右键平移并保留左键框选及资源拖动,完善中断清理
解耦运行不可用提示与资源选中状态
复用原生UI状态校验区分UI设计JSON和普通JSON,接入现有编辑器与代码预览
补充前端和原生回归测试、规范及待验收记录,明确对话历史分页尚未修复
This commit is contained in:
2026-09-16 17:39:23 +08:00
parent b3e9d0a906
commit 910862fd06
25 changed files with 1386 additions and 52 deletions
@@ -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',
),
);
}
/**
* 画布浮层里有自己滚动区的那几个:落在它们里面的滚轮归浮层,画布不得消费。
*
@@ -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 ? (
@@ -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';
}
@@ -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('hookrederiveNow 按 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();
}
});