Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4df4708ba1 | |||
| b3e9d0a906 | |||
| a60328623d | |||
| 9e63b76991 | |||
| a98ebcf68f | |||
| 1e992bcdf8 | |||
| 8368aa262c | |||
| e9ddbf16da | |||
| 22ac1f4c0b | |||
| 9910a0eec0 | |||
| 70981b9ca9 | |||
| 8a0d5600b3 | |||
| 184d88dbb8 | |||
| 791794c0a6 | |||
| 9cfb47d945 | |||
| 27859d3e19 | |||
| 43f3780a38 | |||
| a1f9149c27 | |||
| f0bba18841 | |||
| f679cfa179 | |||
| f68ffcfecd | |||
| ec94a4fe00 | |||
| 46f8592375 | |||
| cdf2882302 | |||
| d156113e71 | |||
| a8f2ac17be | |||
| 43ac9a5761 | |||
| e992e0b35c | |||
| e389b54a3a | |||
| 84c780a6f8 | |||
| 03f900fbe5 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@genarrative/ai-game-creator-shell",
|
||||
"private": true,
|
||||
"version": "0.1.29",
|
||||
"version": "0.1.45",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/start-tauri-dev.mjs",
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { createServer, loadConfigFromFile, normalizePath } from 'vite';
|
||||
|
||||
test(
|
||||
'AGC 排除 Rust 构建目录且保留源码与共享组件监听',
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
const loaded = await loadConfigFromFile(
|
||||
{ command: 'serve', mode: 'development' },
|
||||
fileURLToPath(new URL('../vite.config.ts', import.meta.url)),
|
||||
);
|
||||
assert.ok(loaded);
|
||||
assert.notEqual(loaded.config.server?.watch, null);
|
||||
assert.notEqual(loaded.config.server?.hmr, false);
|
||||
assert.ok(
|
||||
[loaded.config.server?.watch?.ignored]
|
||||
.flat()
|
||||
.includes('**/src-tauri/target/**'),
|
||||
);
|
||||
|
||||
const fixture = await mkdtemp(join(tmpdir(), 'agc-vite-watch-'));
|
||||
const root = join(fixture, 'apps', 'ai-game-creator-shell');
|
||||
const source = join(root, 'src', 'main.js');
|
||||
const css = join(root, 'src', 'styles.css');
|
||||
const shared = join(fixture, 'packages', 'shared', 'src', 'component.js');
|
||||
const target = join(root, 'src-tauri', 'target');
|
||||
const artifact = join(target, 'debug', 'incremental', 'cache.bin');
|
||||
let server;
|
||||
try {
|
||||
for (const file of [source, css, shared, artifact]) {
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
await writeFile(
|
||||
file,
|
||||
file === css ? 'body { color: red; }' : 'export default 1;',
|
||||
);
|
||||
}
|
||||
// 使用真实 Vite watcher 和实际配置,仅将扫描根替换为小型夹具;
|
||||
// 不加载业务插件、后端或原生窗口,也不扫描开发机上的大型 target。
|
||||
server = await createServer({
|
||||
configFile: false,
|
||||
envFile: false,
|
||||
root,
|
||||
logLevel: 'silent',
|
||||
server: {
|
||||
watch: loaded.config.server?.watch,
|
||||
middlewareMode: true,
|
||||
hmr: false,
|
||||
fs: { allow: [fixture] },
|
||||
},
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
});
|
||||
const waitForWatchedFile = async (file) => {
|
||||
const normalized = normalizePath(file);
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
if (
|
||||
Object.entries(server.watcher.getWatched()).some(
|
||||
([directory, names]) =>
|
||||
names.some(
|
||||
(name) => normalizePath(join(directory, name)) === normalized,
|
||||
),
|
||||
)
|
||||
)
|
||||
return;
|
||||
await delay(50);
|
||||
}
|
||||
assert.fail(`源码必须仍被监听:${normalized}`);
|
||||
};
|
||||
await waitForWatchedFile(source);
|
||||
|
||||
// 真实模块转换应将 root 外的共享源码加入监听。
|
||||
await server.transformRequest(`/@fs/${normalizePath(shared)}`);
|
||||
for (const file of [source, css, shared]) {
|
||||
const normalized = normalizePath(file);
|
||||
await waitForWatchedFile(file);
|
||||
const changed = new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
server.watcher.off('change', onChange);
|
||||
reject(new Error(`未收到源码变更:${normalized}`));
|
||||
}, 5_000);
|
||||
function onChange(path) {
|
||||
if (normalizePath(path) !== normalized) return;
|
||||
clearTimeout(timer);
|
||||
server.watcher.off('change', onChange);
|
||||
resolve();
|
||||
}
|
||||
server.watcher.on('change', onChange);
|
||||
});
|
||||
await writeFile(
|
||||
file,
|
||||
file === css ? 'body { color: blue; }' : 'export default 2;',
|
||||
);
|
||||
await changed;
|
||||
}
|
||||
const targetPath = normalizePath(target);
|
||||
const targetDirectories = Object.keys(server.watcher.getWatched())
|
||||
.map(normalizePath)
|
||||
.filter(
|
||||
(path) => path === targetPath || path.startsWith(`${targetPath}/`),
|
||||
);
|
||||
assert.deepEqual(targetDirectories, [], 'Rust target 不应创建目录监听器');
|
||||
} finally {
|
||||
await server?.close();
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
+1
-1
@@ -1725,7 +1725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.29"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"agent-runtime-core",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "genarrative-ai-game-creator-shell"
|
||||
version = "0.1.29"
|
||||
version = "0.1.45"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main",
|
||||
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
|
||||
"description": "AI 游戏创作主窗口允许读写系统剪贴板,用于粘贴素材附件和复制生成文件路径;允许弹出原生打开/保存对话框用于素材上传与导出。",
|
||||
"windows": ["client"],
|
||||
"permissions": [
|
||||
"clipboard-manager:allow-read-image",
|
||||
"clipboard-manager:allow-read-text",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"core:image:allow-rgba",
|
||||
"core:image:allow-size",
|
||||
"core:resources:allow-close",
|
||||
|
||||
@@ -172,6 +172,7 @@ mod materialize {
|
||||
use crate::ui_editor::layout::node::{
|
||||
Node as LayoutNode, NodeMetadata, NodeSource, StageStatus,
|
||||
};
|
||||
use crate::ui_editor::layout::offset::NodeOffset;
|
||||
use crate::ui_editor::layout::transform::Transform;
|
||||
use crate::ui_editor::state::{State, UITree};
|
||||
use crate::ui_editor::utils::{random_node_id, NodeId, UIDesignImageId};
|
||||
@@ -302,6 +303,7 @@ mod materialize {
|
||||
component: None,
|
||||
children_display_mode: ChildrenDisplayMode::Exclusive,
|
||||
children: members.into_iter().map(|member| member.node).collect(),
|
||||
offset: NodeOffset::default(),
|
||||
},
|
||||
priority,
|
||||
src_ui_design,
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||
use crate::ui_editor::layout::dimension::UIRect;
|
||||
use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSource, StageStatus};
|
||||
use crate::ui_editor::layout::offset::NodeOffset;
|
||||
use crate::ui_editor::layout::transform::Transform;
|
||||
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
|
||||
use crate::ui_editor::state::{State, UITree};
|
||||
@@ -322,6 +323,7 @@ fn convert_node(
|
||||
component: source.component.clone().into_option(),
|
||||
children_display_mode: ChildrenDisplayMode::Stack,
|
||||
children,
|
||||
offset: NodeOffset::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -858,6 +860,7 @@ pub(crate) async fn recognize_ui_impl_with_provider(
|
||||
component: recognition_root.component.into_option(),
|
||||
children_display_mode: ChildrenDisplayMode::Stack,
|
||||
children,
|
||||
offset: NodeOffset::default(),
|
||||
};
|
||||
// Tree identity and root identity are assigned by Rust, never chosen by the model.
|
||||
ui_trees.push(UITree {
|
||||
|
||||
@@ -2,4 +2,5 @@ pub mod children_display_mode;
|
||||
pub mod control_layout;
|
||||
pub mod dimension;
|
||||
pub mod node;
|
||||
pub mod offset;
|
||||
pub mod transform;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::ui_editor::component::Component;
|
||||
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
|
||||
use crate::ui_editor::layout::control_layout::ControlLayout;
|
||||
use crate::ui_editor::layout::offset::NodeOffset;
|
||||
use crate::ui_editor::utils::NodeId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
@@ -14,6 +15,7 @@ pub struct Node {
|
||||
pub component: Option<Component>,
|
||||
pub children_display_mode: ChildrenDisplayMode,
|
||||
pub children: Vec<Node>,
|
||||
pub offset: NodeOffset,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||
pub struct NodeOffset {
|
||||
pub min: [f32; 2],
|
||||
pub max: [f32; 2],
|
||||
}
|
||||
|
||||
impl Default for NodeOffset {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min: [0.0, 0.0],
|
||||
max: [0.0, 0.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "陶泥儿",
|
||||
"version": "0.1.29",
|
||||
"version": "0.1.45",
|
||||
"identifier": "world.genarrative.ai-game-creator",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm --prefix ../.. run agc:serve",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Node as UiNode } from './types/Node';
|
||||
import type { NodeId } from './types/NodeId';
|
||||
import type { NodeMetadata } from './types/NodeMetadata';
|
||||
import type { StageStatus } from './types/StageStatus';
|
||||
import type { UIDesignImageId } from './types/UIDesignImageId';
|
||||
@@ -14,6 +15,11 @@ export type UiTreeNodeTarget = {
|
||||
node: UiNode;
|
||||
};
|
||||
|
||||
export type UiTreeNodeCursor = {
|
||||
treeId: UIDesignImageId;
|
||||
nodeId: NodeId;
|
||||
};
|
||||
|
||||
export type StageStatusOverview = {
|
||||
total: number;
|
||||
needsAttention: number;
|
||||
@@ -79,22 +85,28 @@ export function getStageStatusTargets(
|
||||
|
||||
export function getNextUiTreeNodeTarget(
|
||||
targets: UiTreeNodeTarget[],
|
||||
previousNodeId: string | null,
|
||||
previous: string | UiTreeNodeCursor | null,
|
||||
): UiTreeNodeTarget | null {
|
||||
if (targets.length === 0) return null;
|
||||
const previousIndex = targets.findIndex(
|
||||
({ node }) => node.id === previousNodeId,
|
||||
);
|
||||
const previousIndex =
|
||||
typeof previous === 'string'
|
||||
? targets.findIndex(({ node }) => node.id === previous)
|
||||
: previous
|
||||
? targets.findIndex(
|
||||
({ treeId, node }) =>
|
||||
treeId === previous.treeId && node.id === previous.nodeId,
|
||||
)
|
||||
: -1;
|
||||
return targets[(previousIndex + 1) % targets.length] ?? null;
|
||||
}
|
||||
|
||||
export function getNextMatchingUiTreeNodeTarget(
|
||||
uiTrees: UITree[],
|
||||
previousNodeId: string | null,
|
||||
previous: string | UiTreeNodeCursor | null,
|
||||
matches: (target: UiTreeNodeTarget) => boolean,
|
||||
): UiTreeNodeTarget | null {
|
||||
return getNextUiTreeNodeTarget(
|
||||
collectUiTreeNodeTargets(uiTrees).filter(matches),
|
||||
previousNodeId,
|
||||
previous,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ import type { Component } from "./Component";
|
||||
import type { ControlLayout } from "./ControlLayout";
|
||||
import type { NodeId } from "./NodeId";
|
||||
import type { NodeMetadata } from "./NodeMetadata";
|
||||
import type { NodeOffset } from "./NodeOffset";
|
||||
|
||||
export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array<Node>, };
|
||||
export type Node = { id: NodeId, layout: ControlLayout, metadata: NodeMetadata, component: Component | null, children_display_mode: ChildrenDisplayMode, children: Array<Node>, offset: NodeOffset };
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export type NodeOffset = {
|
||||
min: [number, number];
|
||||
max: [number, number];
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import type { FontAssetId } from './types/FontAssetId';
|
||||
import type { Node } from './types/Node';
|
||||
import type { NodeId } from './types/NodeId';
|
||||
import type { NodeMetadata } from './types/NodeMetadata';
|
||||
import type { NodeOffset } from './types/NodeOffset';
|
||||
import type { SpriteAsset } from './types/SpriteAsset';
|
||||
import type { SpriteAssetId } from './types/SpriteAssetId';
|
||||
import type { SpriteBorder } from './types/SpriteBorder';
|
||||
@@ -34,6 +35,7 @@ export const EMPTY_UI_EDITOR_STATE: State = {
|
||||
};
|
||||
|
||||
const MAX_HISTORY_LENGTH = 100;
|
||||
export const UI_TREE_PADDING = 48;
|
||||
|
||||
export type UiEditorOperationFailureReason =
|
||||
| 'locked'
|
||||
@@ -156,6 +158,7 @@ function createPageRoot(state: State): Node {
|
||||
component: null,
|
||||
children_display_mode: 'Stack',
|
||||
children: [],
|
||||
offset: { min: [0, 0], max: [0, 0] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -187,9 +190,53 @@ function createHumanNode(state: State): Node {
|
||||
component: null,
|
||||
children_display_mode: 'Stack',
|
||||
children: [],
|
||||
offset: { min: [0, 0], max: [0, 0] },
|
||||
};
|
||||
}
|
||||
|
||||
function treeSize(state: State, treeId: UIDesignImageId): [number, number] {
|
||||
const image = state.ui_design_images[treeId];
|
||||
if (
|
||||
!image ||
|
||||
!Number.isFinite(image.pixels_per_unit) ||
|
||||
image.pixels_per_unit <= 0
|
||||
) {
|
||||
throw new Error(`界面图 ${treeId} 缺少合法尺寸`);
|
||||
}
|
||||
return [
|
||||
image.pixel_size[0] / image.pixels_per_unit,
|
||||
image.pixel_size[1] / image.pixels_per_unit,
|
||||
];
|
||||
}
|
||||
|
||||
function deriveTreeOffset(state: State, treeId: UIDesignImageId): NodeOffset {
|
||||
const [width, height] = treeSize(state, treeId);
|
||||
const existing = state.ui_trees.filter(
|
||||
(tree) => tree.src_ui_design !== treeId,
|
||||
);
|
||||
if (existing.length === 0) return { min: [0, 0], max: [width, height] };
|
||||
const maxX = Math.max(
|
||||
...existing.map(
|
||||
(tree) =>
|
||||
tree.root.offset.min[0] + treeSize(state, tree.src_ui_design)[0],
|
||||
),
|
||||
);
|
||||
const minY = Math.min(...existing.map((tree) => tree.root.offset.min[1]));
|
||||
return {
|
||||
min: [maxX + UI_TREE_PADDING, minY],
|
||||
max: [maxX + UI_TREE_PADDING + width, minY + height],
|
||||
};
|
||||
}
|
||||
|
||||
export function createTree(
|
||||
state: State,
|
||||
treeId: UIDesignImageId,
|
||||
root = createPageRoot(state),
|
||||
) {
|
||||
root.offset = deriveTreeOffset(state, treeId);
|
||||
return { src_ui_design: treeId, root };
|
||||
}
|
||||
|
||||
function synchronizeDesignImageTrees(state: State): void {
|
||||
const imageIds = new Set(Object.keys(state.ui_design_images));
|
||||
state.ui_trees = state.ui_trees.filter((tree) =>
|
||||
@@ -197,7 +244,7 @@ function synchronizeDesignImageTrees(state: State): void {
|
||||
);
|
||||
for (const [id] of Object.entries(state.ui_design_images)) {
|
||||
if (!state.ui_trees.some((tree) => tree.src_ui_design === id)) {
|
||||
state.ui_trees.push({ src_ui_design: id, root: createPageRoot(state) });
|
||||
state.ui_trees.push(createTree(state, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -951,6 +998,34 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
[commit, guard],
|
||||
);
|
||||
|
||||
const setTreeOffset = useCallback(
|
||||
(
|
||||
treeId: UIDesignImageId,
|
||||
min: [number, number],
|
||||
): UiEditorOperationResult => {
|
||||
const blocked = guard();
|
||||
if (blocked) return blocked;
|
||||
if (!min.every(Number.isFinite)) return { ok: false, reason: 'invalid' };
|
||||
const current = stateRef.current;
|
||||
const tree = current.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
);
|
||||
if (!tree) return { ok: false, reason: 'missing' };
|
||||
const size = treeSize(current, treeId);
|
||||
const next = cloneState(current);
|
||||
const nextTree = next.ui_trees.find(
|
||||
(candidate) => candidate.src_ui_design === treeId,
|
||||
)!;
|
||||
nextTree.root.offset = {
|
||||
min: [...min],
|
||||
max: [min[0] + size[0], min[1] + size[1]],
|
||||
};
|
||||
commit(next);
|
||||
return { ok: true, value: undefined };
|
||||
},
|
||||
[commit, guard],
|
||||
);
|
||||
|
||||
const insertNodeAfter = useCallback(
|
||||
(
|
||||
treeId: UIDesignImageId,
|
||||
@@ -1464,6 +1539,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
|
||||
setSpriteBorder,
|
||||
insertNode,
|
||||
insertNodeAfter,
|
||||
setTreeOffset,
|
||||
deleteNode,
|
||||
setNodeTransform,
|
||||
setNodeLayout,
|
||||
|
||||
@@ -70,6 +70,7 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
|
||||
component: null,
|
||||
children_display_mode: 'Stack',
|
||||
children: uiTrees.map((tree) => tree.root),
|
||||
offset: { min: [0, 0], max: [0, 0] },
|
||||
};
|
||||
}, [uiTrees]);
|
||||
|
||||
@@ -83,8 +84,14 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
|
||||
treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null}
|
||||
isNodePreviewVisible={input.isNodePreviewVisible}
|
||||
onSelectNode={(treeId, nodeId) => {
|
||||
input.selectDesignImage(treeId);
|
||||
input.selectNode(nodeId);
|
||||
// react-arborist emits `onSelect` when its controlled `selection`
|
||||
// prop is updated. Overview navigation updates the selection and
|
||||
// the status highlight in the same render, so treating that
|
||||
// programmatic notification as a fresh user selection would clear
|
||||
// the highlight before it can be painted. Only mutate selection
|
||||
// state when the target actually differs from the current one.
|
||||
const sameNode = input.selectedNodeId === nodeId;
|
||||
if (!sameNode) input.selectNode(nodeId);
|
||||
}}
|
||||
onToggleNodeVisibility={(nodeId) =>
|
||||
input.toggleNodePreviewVisibility(nodeId)
|
||||
|
||||
+24
-8
@@ -284,7 +284,7 @@ function NodeInspector({
|
||||
onMetadataChange,
|
||||
highlightedStatusField,
|
||||
onTransformChange,
|
||||
onLayoutChange,
|
||||
// onLayoutChange,
|
||||
sprites,
|
||||
previewUrls,
|
||||
fonts,
|
||||
@@ -483,11 +483,11 @@ function NodeInspector({
|
||||
readOnly={transformReadOnly || isReadOnly}
|
||||
onChange={onTransformChange}
|
||||
/>
|
||||
<LayoutEditor
|
||||
node={node}
|
||||
readOnly={isReadOnly}
|
||||
onChange={onLayoutChange}
|
||||
/>
|
||||
{/*<LayoutEditor*/}
|
||||
{/* node={node}*/}
|
||||
{/* readOnly={isReadOnly}*/}
|
||||
{/* onChange={onLayoutChange}*/}
|
||||
{/*/>*/}
|
||||
<ComponentPanel
|
||||
key={node.id}
|
||||
component={node.component}
|
||||
@@ -511,6 +511,7 @@ function NodeInspector({
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
function LayoutEditor({
|
||||
node,
|
||||
readOnly,
|
||||
@@ -826,11 +827,26 @@ function NodeStageSelect({
|
||||
const attentionTone = kind === 'Blocked' ? 'blocked' : 'review';
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlight) return;
|
||||
statusRowRef.current?.scrollIntoView({
|
||||
const statusRow = statusRowRef.current;
|
||||
if (!highlight || !statusRow) return;
|
||||
statusRow.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
|
||||
// 强制制造一次样式边界,避免 A → B → A 时浏览器复用已完成的动画。
|
||||
statusRow.classList.remove('ui-editor-status-attention');
|
||||
void statusRow.offsetWidth;
|
||||
statusRow.classList.add('ui-editor-status-attention');
|
||||
|
||||
// 多节点切换会复用 Inspector 树,显式重启动画,确保回到已查看节点时
|
||||
// 仍能再次播放提示,而不是依赖 class/key 的重协调行为。
|
||||
if (typeof statusRow.getAnimations === 'function') {
|
||||
for (const animation of statusRow.getAnimations()) {
|
||||
animation.cancel();
|
||||
animation.play();
|
||||
}
|
||||
}
|
||||
}, [highlight]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -44,7 +44,7 @@ export function RecognitionOverview({
|
||||
Overview
|
||||
</span>
|
||||
<h2 className="m-0 text-sm font-semibold">识别概览</h2>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div className="mt-3 grid grid-cols-4 gap-2">
|
||||
<OverviewValue label="已识别 Node" value={overview.total} />
|
||||
<OverviewAction
|
||||
label="待用户检查"
|
||||
|
||||
@@ -47,7 +47,7 @@ export function SeparationOverview({
|
||||
Overview
|
||||
</span>
|
||||
<h2 className="m-0 text-sm font-semibold">自动切分素材概览</h2>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div className="mt-3 grid grid-cols-4 gap-2">
|
||||
<OverviewValue
|
||||
label="需要切分素材的组件"
|
||||
value={overview.componentsNeedingAssets}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function UiEditorCopyPathButton({
|
||||
relativePath,
|
||||
}: {
|
||||
relativePath: string;
|
||||
}) {
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
|
||||
useEffect(() => setCopyState('idle'), [relativePath]);
|
||||
|
||||
async function copyPath() {
|
||||
setCopyState('idle');
|
||||
try {
|
||||
await writeText(relativePath);
|
||||
setCopyState('copied');
|
||||
} catch (error) {
|
||||
setCopyState('failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{copyState === 'failed' ? (
|
||||
<p className="mt-2 text-xs text-red-600" role="alert">
|
||||
复制失败,请手动复制路径。
|
||||
</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
|
||||
onClick={() => void copyPath()}
|
||||
>
|
||||
<Copy size={14} aria-hidden="true" />
|
||||
{copyState === 'copied' ? '已复制' : '复制路径'}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ThemedModal } from '../../../components/modal/ThemedModal';
|
||||
import { UiEditorCopyPathButton } from './UiEditorCopyPathButton';
|
||||
|
||||
export type UiEditorSaveResultNotice =
|
||||
| {
|
||||
kind: 'saved';
|
||||
}
|
||||
| {
|
||||
kind: 'generated';
|
||||
relativePath: string;
|
||||
}
|
||||
| {
|
||||
kind: 'failure';
|
||||
message: string;
|
||||
retryLabel?: string;
|
||||
onRetry?: () => void;
|
||||
};
|
||||
|
||||
export function UiEditorSaveResultModal({
|
||||
notice,
|
||||
onClose,
|
||||
}: {
|
||||
notice: UiEditorSaveResultNotice | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ThemedModal
|
||||
open={notice !== null}
|
||||
onClose={onClose}
|
||||
ariaLabel={ariaLabelForNotice(notice)}
|
||||
panelClassName="w-[min(460px,calc(100vw-2rem))] rounded-2xl p-5"
|
||||
>
|
||||
{notice?.kind === 'generated' ? (
|
||||
<GeneratedNotice relativePath={notice.relativePath} onClose={onClose} />
|
||||
) : notice?.kind === 'saved' ? (
|
||||
<SimpleNotice title="保存成功" onClose={onClose} />
|
||||
) : notice?.kind === 'failure' ? (
|
||||
<FailureNotice notice={notice} onClose={onClose} />
|
||||
) : null}
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneratedNotice({
|
||||
relativePath,
|
||||
onClose,
|
||||
}: {
|
||||
relativePath: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="m-0 text-base font-semibold">代码已生成</h2>
|
||||
<p className="mt-3 text-sm text-(--platform-text-soft)">生成文件路径</p>
|
||||
<code className="mt-2 block select-text break-all rounded-lg bg-black/5 px-3 py-2 text-xs leading-5">
|
||||
{relativePath}
|
||||
</code>
|
||||
<div className="mt-5">
|
||||
<UiEditorCopyPathButton relativePath={relativePath} />
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SimpleNotice({
|
||||
title,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="m-0 text-base font-semibold">{title}</h2>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FailureNotice({
|
||||
notice,
|
||||
onClose,
|
||||
}: {
|
||||
notice: Extract<UiEditorSaveResultNotice, { kind: 'failure' }>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="m-0 text-base font-semibold">操作失败</h2>
|
||||
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-red-700">
|
||||
{notice.message}
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
{notice.onRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-orange-600 px-3 py-2 text-xs font-semibold text-white"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
notice.onRetry?.();
|
||||
}}
|
||||
>
|
||||
{notice.retryLabel ?? '重试'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ariaLabelForNotice(notice: UiEditorSaveResultNotice | null) {
|
||||
if (notice?.kind === 'generated') return '代码已生成';
|
||||
if (notice?.kind === 'saved') return '保存成功';
|
||||
if (notice?.kind === 'failure') return '操作失败';
|
||||
return '保存结果';
|
||||
}
|
||||
+243
-179
File diff suppressed because it is too large
Load Diff
+16
-13
@@ -20,17 +20,17 @@ import { resolveExclusiveVisibleChildId } from './exclusiveVisibility';
|
||||
|
||||
export type { ResizeHandle } from '../../../../features/ui-editor/nodeTransformGeometry';
|
||||
|
||||
export type UiEditorRenderMode = 'editor-overlay' | 'final-preview';
|
||||
|
||||
type NodePointerDown = (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
node: UiNode,
|
||||
treeId?: string,
|
||||
) => void;
|
||||
|
||||
type UiTreeRendererProps = {
|
||||
tree: UITree | null;
|
||||
renderMode: UiEditorRenderMode;
|
||||
treeId?: string;
|
||||
showFrame: boolean;
|
||||
showComponent: boolean;
|
||||
hiddenNodeIds: ReadonlySet<NodeId>;
|
||||
previewTransforms?: ReadonlyMap<NodeId, UiNode['layout']['transform']>;
|
||||
selectedNodeId: NodeId | null;
|
||||
@@ -41,6 +41,7 @@ type UiTreeRendererProps = {
|
||||
event: ReactMouseEvent<HTMLDivElement>,
|
||||
node: UiNode,
|
||||
isPageRoot: boolean,
|
||||
treeId?: string,
|
||||
) => void;
|
||||
onNodePointerDown: NodePointerDown;
|
||||
onNodePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
@@ -50,6 +51,7 @@ type UiTreeRendererProps = {
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
node: UiNode,
|
||||
handle: ResizeHandle,
|
||||
treeId?: string,
|
||||
) => void;
|
||||
onNodeResizePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onNodeResizePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
@@ -81,10 +83,11 @@ const EMPTY_PREVIEW_TRANSFORMS: ReadonlyMap<
|
||||
|
||||
function RenderNode({
|
||||
node,
|
||||
treeId,
|
||||
isRoot,
|
||||
parentContainer,
|
||||
renderMode,
|
||||
showFrame,
|
||||
showComponent,
|
||||
hiddenNodeIds,
|
||||
previewTransforms,
|
||||
selectedNodeId,
|
||||
@@ -124,8 +127,8 @@ function RenderNode({
|
||||
return null;
|
||||
}
|
||||
|
||||
const isEditorOverlay = renderMode === 'editor-overlay';
|
||||
const isFrameVisible = isEditorOverlay || showFrame;
|
||||
const isSelected = selectedNodeId === node.id;
|
||||
const isFrameVisible = showFrame || isSelected;
|
||||
const receivesPointerGesture = parentContainer === undefined;
|
||||
const hasDirectPointerGesture = receivesPointerGesture && !isRoot;
|
||||
const exclusiveVisibleChildId =
|
||||
@@ -170,15 +173,14 @@ function RenderNode({
|
||||
}
|
||||
}
|
||||
onContextMenu={(event) => {
|
||||
if (!isEditorOverlay) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSelectNode(node.id);
|
||||
onNodeContextMenu(event, node, Boolean(isRoot));
|
||||
onNodeContextMenu(event, node, Boolean(isRoot), treeId);
|
||||
}}
|
||||
onPointerDown={
|
||||
receivesPointerGesture
|
||||
? (event) => onNodePointerDown(event, node)
|
||||
? (event) => onNodePointerDown(event, node, treeId)
|
||||
: undefined
|
||||
}
|
||||
onPointerMove={receivesPointerGesture ? onNodePointerMove : undefined}
|
||||
@@ -191,10 +193,10 @@ function RenderNode({
|
||||
{node.metadata.name}
|
||||
</span>
|
||||
) : null}
|
||||
{node.component ? (
|
||||
{showComponent && node.component ? (
|
||||
<ComponentView component={node.component} resources={resources} />
|
||||
) : null}
|
||||
{renderMode === 'final-preview' && selectedNodeId === node.id ? (
|
||||
{isSelected ? (
|
||||
<ExclusiveChildrenTabs
|
||||
parent={node}
|
||||
isChildVisible={(nodeId) => nodeId === exclusiveVisibleChildId}
|
||||
@@ -207,13 +209,14 @@ function RenderNode({
|
||||
<RenderNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
treeId={treeId}
|
||||
parentContainer={
|
||||
isContainer(node.layout.container)
|
||||
? node.layout.container
|
||||
: undefined
|
||||
}
|
||||
renderMode={renderMode}
|
||||
showFrame={showFrame}
|
||||
showComponent={showComponent}
|
||||
hiddenNodeIds={hiddenNodeIds}
|
||||
previewTransforms={activePreviewTransforms}
|
||||
selectedNodeId={selectedNodeId}
|
||||
@@ -254,7 +257,7 @@ function RenderNode({
|
||||
touchAction: 'none',
|
||||
}}
|
||||
onPointerDown={(event) =>
|
||||
onNodeResizePointerDown(event, node, handle.id)
|
||||
onNodeResizePointerDown(event, node, handle.id, treeId)
|
||||
}
|
||||
onPointerMove={onNodeResizePointerMove}
|
||||
onPointerUp={onNodeResizePointerUp}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/** 背景网格的基础世界步长;缩放时按 2 倍档位调整屏幕密度。 */
|
||||
export const PREVIEW_GRID_BASE_STEP = 28;
|
||||
const PREVIEW_GRID_MIN_SCREEN_SPACING = 20;
|
||||
const PREVIEW_GRID_MAX_SCREEN_SPACING = 40;
|
||||
|
||||
/**
|
||||
* 选择一个离散的世界步长,让网格圆点在屏幕上保持可读密度。
|
||||
* 背景位置仍使用 viewport 的屏幕平移量,因此切档不会破坏世界原点对齐。
|
||||
*/
|
||||
export function resolvePreviewGridStep(viewportScale: number): number {
|
||||
if (!Number.isFinite(viewportScale) || viewportScale <= 0) {
|
||||
return PREVIEW_GRID_BASE_STEP;
|
||||
}
|
||||
|
||||
let step = PREVIEW_GRID_BASE_STEP;
|
||||
let screenSpacing = step * viewportScale;
|
||||
while (screenSpacing < PREVIEW_GRID_MIN_SCREEN_SPACING) {
|
||||
step *= 2;
|
||||
screenSpacing *= 2;
|
||||
}
|
||||
while (screenSpacing >= PREVIEW_GRID_MAX_SCREEN_SPACING) {
|
||||
step /= 2;
|
||||
screenSpacing /= 2;
|
||||
}
|
||||
return step;
|
||||
}
|
||||
+42
-41
@@ -176,22 +176,20 @@ function emitPreviewTransforms(
|
||||
}
|
||||
|
||||
export function useNodeTransformInteraction({
|
||||
activeImageId,
|
||||
canvas,
|
||||
logicalSize,
|
||||
trees,
|
||||
logicalSizes,
|
||||
spaceHeld,
|
||||
tree,
|
||||
keepChildrenUnchanged,
|
||||
viewportRef,
|
||||
onPreviewTransform,
|
||||
previewRef,
|
||||
selectedNodeId,
|
||||
}: {
|
||||
activeImageId: UiEditorCanvasProjection['activeImageId'];
|
||||
canvas: Pick<UiEditorCanvasProjection, 'selectNode' | 'updateNodeTransform'>;
|
||||
logicalSize: { width: number; height: number } | null;
|
||||
trees: readonly UITree[];
|
||||
logicalSizes: ReadonlyMap<string, { width: number; height: number }>;
|
||||
spaceHeld: boolean;
|
||||
tree: UITree | null;
|
||||
keepChildrenUnchanged: boolean;
|
||||
viewportRef: RefObject<ViewportScale>;
|
||||
onPreviewTransform?: (
|
||||
@@ -229,36 +227,30 @@ export function useNodeTransformInteraction({
|
||||
return () => window.removeEventListener('blur', cancelGesture);
|
||||
}, [cancelGesture]);
|
||||
|
||||
useEffect(() => {
|
||||
const gesture = activeGestureRef.current;
|
||||
if (
|
||||
gesture &&
|
||||
(gesture.treeId !== activeImageId ||
|
||||
tree?.src_ui_design !== gesture.treeId)
|
||||
) {
|
||||
cancelGesture();
|
||||
}
|
||||
}, [activeImageId, cancelGesture, tree]);
|
||||
|
||||
const acceptsGestureEvent = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const gesture = activeGestureRef.current;
|
||||
return (
|
||||
gesture !== null &&
|
||||
gesture.pointerId === event.pointerId &&
|
||||
gesture.treeId === activeImageId
|
||||
);
|
||||
return gesture !== null && gesture.pointerId === event.pointerId;
|
||||
},
|
||||
[activeImageId],
|
||||
[],
|
||||
);
|
||||
|
||||
const onNodePointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>, node: UiNode) => {
|
||||
(
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
node: UiNode,
|
||||
treeId?: string,
|
||||
) => {
|
||||
const tree = treeId
|
||||
? (trees.find((candidate) => candidate.src_ui_design === treeId) ??
|
||||
null)
|
||||
: null;
|
||||
const logicalSize = treeId ? (logicalSizes.get(treeId) ?? null) : null;
|
||||
if (
|
||||
activeGestureRef.current !== null ||
|
||||
event.button !== 0 ||
|
||||
spaceHeld ||
|
||||
!activeImageId ||
|
||||
!treeId ||
|
||||
node.id === tree?.root.id ||
|
||||
!isFiniteTransform(node.layout.transform)
|
||||
) {
|
||||
@@ -275,7 +267,7 @@ export function useNodeTransformInteraction({
|
||||
event.preventDefault();
|
||||
activeGestureRef.current = {
|
||||
kind: 'drag',
|
||||
treeId: activeImageId,
|
||||
treeId,
|
||||
nodeId: dragNode.id,
|
||||
pointerId: event.pointerId,
|
||||
target: event.currentTarget,
|
||||
@@ -292,14 +284,7 @@ export function useNodeTransformInteraction({
|
||||
),
|
||||
};
|
||||
},
|
||||
[
|
||||
activeImageId,
|
||||
keepChildrenUnchanged,
|
||||
logicalSize,
|
||||
spaceHeld,
|
||||
selectedNodeId,
|
||||
tree,
|
||||
],
|
||||
[keepChildrenUnchanged, logicalSizes, spaceHeld, selectedNodeId, trees],
|
||||
);
|
||||
|
||||
const onNodePointerMove = useCallback(
|
||||
@@ -309,6 +294,11 @@ export function useNodeTransformInteraction({
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
const tree =
|
||||
trees.find((candidate) => candidate.src_ui_design === gesture.treeId) ??
|
||||
null;
|
||||
const logicalSize = logicalSizes.get(gesture.treeId) ?? null;
|
||||
if (!tree || !logicalSize) return;
|
||||
if (!gesture.hasMoved && !passedDragThreshold(gesture, event)) return;
|
||||
const scale = viewportRef.current?.scale;
|
||||
if (!Number.isFinite(scale) || scale <= 0) {
|
||||
@@ -346,8 +336,8 @@ export function useNodeTransformInteraction({
|
||||
acceptsGestureEvent,
|
||||
cancelGesture,
|
||||
keepChildrenUnchanged,
|
||||
logicalSize,
|
||||
tree,
|
||||
logicalSizes,
|
||||
trees,
|
||||
viewportRef,
|
||||
],
|
||||
);
|
||||
@@ -389,12 +379,18 @@ export function useNodeTransformInteraction({
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
node: UiNode,
|
||||
handle: ResizeHandle,
|
||||
treeId?: string,
|
||||
) => {
|
||||
const tree = treeId
|
||||
? (trees.find((candidate) => candidate.src_ui_design === treeId) ??
|
||||
null)
|
||||
: null;
|
||||
const logicalSize = treeId ? (logicalSizes.get(treeId) ?? null) : null;
|
||||
if (
|
||||
activeGestureRef.current !== null ||
|
||||
event.button !== 0 ||
|
||||
spaceHeld ||
|
||||
!activeImageId ||
|
||||
!treeId ||
|
||||
node.id === tree?.root.id ||
|
||||
!tree ||
|
||||
!logicalSize ||
|
||||
@@ -424,7 +420,7 @@ export function useNodeTransformInteraction({
|
||||
suppressNextNodeClickRef.current = false;
|
||||
activeGestureRef.current = {
|
||||
kind: 'resize',
|
||||
treeId: activeImageId,
|
||||
treeId,
|
||||
nodeId: node.id,
|
||||
pointerId: event.pointerId,
|
||||
target: event.currentTarget,
|
||||
@@ -445,7 +441,7 @@ export function useNodeTransformInteraction({
|
||||
),
|
||||
};
|
||||
},
|
||||
[activeImageId, keepChildrenUnchanged, logicalSize, spaceHeld, tree],
|
||||
[keepChildrenUnchanged, logicalSizes, spaceHeld, trees],
|
||||
);
|
||||
|
||||
const onNodeResizePointerMove = useCallback(
|
||||
@@ -459,6 +455,11 @@ export function useNodeTransformInteraction({
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
const tree =
|
||||
trees.find((candidate) => candidate.src_ui_design === gesture.treeId) ??
|
||||
null;
|
||||
const logicalSize = logicalSizes.get(gesture.treeId) ?? null;
|
||||
if (!tree || !logicalSize) return;
|
||||
if (!gesture.hasMoved && !passedDragThreshold(gesture, event)) return;
|
||||
const scale = viewportRef.current?.scale;
|
||||
if (!Number.isFinite(scale) || scale <= 0) {
|
||||
@@ -517,8 +518,8 @@ export function useNodeTransformInteraction({
|
||||
acceptsGestureEvent,
|
||||
cancelGesture,
|
||||
keepChildrenUnchanged,
|
||||
logicalSize,
|
||||
tree,
|
||||
logicalSizes,
|
||||
trees,
|
||||
viewportRef,
|
||||
],
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user