Compare commits

...

45 Commits

Author SHA1 Message Date
k88936 d558cfe42d 修复预览画布快捷键测试的平台兼容
根据 navigator.platform 发送 Ctrl 或 Cmd 修饰键。

避免 Apple CI 下快捷键被正确忽略导致断言读取初始缩放值。
2026-09-08 16:00:45 +08:00
k88936 3e6fbaed9a 修复预览画布缩放百分比编辑
Project CI / Repository checks (pull_request) Successful in 2m45s
Project CI / Frontend tests (pull_request) Successful in 2m50s
Project CI / Backend tests (pull_request) Successful in 6m40s
Project CI / Native shell tests (pull_request) Successful in 17m32s
将百分比按钮替换为可编辑数字输入框

失焦时限制缩放范围并同步画布视口

新增百分比输入组件级回归测试并更新交互文档
2026-09-08 11:17:12 +08:00
k88936 3fa19600f2 Merge remote-tracking branch 'origin/fix/ui-editor-minor-adjusts' into fix/ui-editor-minor-adjusts
Project CI / Repository checks (pull_request) Successful in 2m21s
Project CI / Frontend tests (pull_request) Successful in 3m0s
Project CI / Backend tests (pull_request) Successful in 6m58s
Project CI / Native shell tests (pull_request) Successful in 17m24s
2026-09-07 18:49:04 +08:00
k88936 1a03bfebf6 修复UI编辑器通知弹窗文档末尾空行
删除文档末尾多余空行,保留标准EOF换行
2026-09-07 18:26:47 +08:00
k88936 777f928830 Merge branch 'master' into fix/ui-editor-minor-adjusts
Project CI / Frontend tests (pull_request) Successful in 2m38s
Project CI / Repository checks (pull_request) Failing after 2m13s
Project CI / Backend tests (pull_request) Successful in 6m34s
Project CI / Native shell tests (pull_request) Successful in 19m41s
2026-09-07 17:26:21 +08:00
k88936 a08b224590 Merge branch 'master' into fix/ui-editor-minor-adjusts
Project CI / Repository checks (pull_request) Failing after 2m16s
Project CI / Backend tests (pull_request) Successful in 7m7s
Project CI / Frontend tests (pull_request) Successful in 2m56s
Project CI / Native shell tests (pull_request) Successful in 20m43s
2026-09-07 15:44:17 +08:00
k88936 3f8b438fdc Merge branch 'master' into fix/ui-editor-minor-adjusts
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
2026-09-07 13:11:54 +08:00
k88936 584b0f95cb Merge remote-tracking branch 'origin/fix/ui-editor-minor-adjusts' into fix/ui-editor-minor-adjusts
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
2026-09-07 12:49:13 +08:00
k88936 933ddca561 Merge branch 'master' into fix/ui-editor-minor-adjusts 2026-09-07 11:12:56 +08:00
k88936 6271026e2d Merge branch 'master' into fix/ui-editor-minor-adjusts
Project CI / Repository checks (pull_request) Failing after 2m38s
Project CI / Frontend tests (pull_request) Successful in 3m8s
Project CI / Backend tests (pull_request) Successful in 6m35s
Project CI / Native shell tests (pull_request) Successful in 21m42s
2026-09-05 19:55:27 +08:00
k88936 29b0b944bd 修复缩放控件焦点拦截节点删除
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 20s
Project CI / Frontend tests (pull_request) Successful in 3m37s
Project CI / Native shell tests (pull_request) Successful in 21m7s
允许缩放滑杆和缩放按钮焦点下继续执行 Delete/Backspace。

修正 UI 树宽度测量重复节点文本并保持预览点击选择语义。

补充缩放焦点快捷键回归测试与交互文档。
2026-09-05 19:54:06 +08:00
k88936 df72dd4bed 接入预览画布加减快捷缩放
复用工具栏中心缩放逻辑并统一加减倍率常量
为预览画布增加悬停与键盘焦点作用域
保留既有适配画布和实际尺寸快捷键行为
2026-09-05 19:49:27 +08:00
k88936 af8e1df139 封装预览画布快捷缩放判定
识别跨平台加减、适配画布与实际尺寸快捷键
限制快捷键作用域并放行可编辑和可操作目标
增加按键变体、重复输入和默认行为回归测试
2026-09-05 19:33:10 +08:00
k88936 c50960f4c4 明确预览画布快捷缩放约定
区分滑杆选择范围与快捷加减的核心视口边界
约定跨平台按键、作用域、事件拦截与既有重置行为
补充组件级回归测试和定向验证标准
2026-09-05 19:25:33 +08:00
k88936 9deae852ec 补充 UI 树布局约定
记录节点操作列与树横向滚动规则
说明深层节点名称不得截断
2026-09-05 18:46:58 +08:00
k88936 39736fb54f 优化 UI 树节点操作布局
统一节点操作列宽度并对齐可见性与删除按钮
为树内容增加横向滚动和动态宽度测量
组件数量改用实心橙色拼图图标徽标
2026-09-05 18:46:37 +08:00
k88936 98c5768ebf 修正缩放滑杆百分比映射
Project CI / Repository checks (pull_request) Failing after 2m35s
Project CI / Frontend tests (pull_request) Failing after 2m9s
Project CI / Native shell tests (pull_request) Failing after 4m57s
Project CI / Backend tests (pull_request) Successful in 6m33s
使用显示百分比同步滑块位置
2026-09-05 14:42:37 +08:00
k88936 9d53c44241 优化预览画布缩放滑杆
新增桌面端可拖动缩放滑杆

保留按钮与视口中心缩放行为
2026-09-05 14:42:02 +08:00
k88936 dd6bd0cb7b 补充预览画布缩放滑杆交互文档
新增桌面端缩放滑杆交互约定

明确范围、键盘操作与验收标准
2026-09-05 14:40:57 +08:00
k88936 48b78c1b33 Merge remote-tracking branch 'origin/fix/ui-editor-minor-adjusts' into fix/ui-editor-minor-adjusts 2026-09-05 13:59:09 +08:00
k88936 d8d4501665 补充弹窗快捷键规范
明确对话框打开时暂停编辑器级撤销重做
2026-09-05 13:54:28 +08:00
k88936 5b75792d6a 修复弹窗内编辑器快捷键误触发
抽取UI编辑器键盘快捷键处理模块

阻止对话框内撤销重做并保留普通按钮快捷键行为
2026-09-05 13:54:26 +08:00
k88936 84222dd44e Merge branch 'master' into fix/ui-editor-minor-adjusts
Project CI / Native shell tests (pull_request) Failing after 4m11s
Project CI / Frontend tests (pull_request) Failing after 30m6s
Project CI / Repository checks (pull_request) Failing after 30m26s
Project CI / Backend tests (pull_request) Successful in 35m27s
2026-09-05 13:50:25 +08:00
k88936 897683e63f 补充UI树删除计数测试
覆盖嵌套节点定位与后代节点递归计数
2026-09-05 13:17:51 +08:00
k88936 8242885c79 新增UI树节点快捷删除
在可删除节点行提供桌面端垃圾桶按钮

复用右键删除入口并对后代节点显示确认弹窗
2026-09-05 13:17:49 +08:00
k88936 b4ba318849 补充UI树快捷删除方案
记录桌面端快捷删除按钮与右键删除复用规则

明确后代节点确认提示、页面根禁删和底层命令边界
2026-09-05 13:01:35 +08:00
k88936 6f29c60d63 统一节点选区的严格空值判断
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / Frontend tests (pull_request) Successful in 2m57s
Project CI / Native shell tests (pull_request) Successful in 18m0s
将 selectedNodeId 的宽松不等比较改为 !== null
2026-09-04 18:07:08 +08:00
k88936 6f0bae7724 让工作流步骤标题映射显式穷举
使用覆盖全部 UiEditorStepId 的 switch 映射步骤标题

增加 never 穷举检查以便新增步骤触发类型错误
2026-09-04 18:06:47 +08:00
k88936 efd67911c9 提取工作流完成状态上报逻辑
统一成功与失败分支的提示拼接、状态更新和终态通知

保持三个工作流步骤现有消息与状态行为不变
2026-09-04 18:06:03 +08:00
k88936 f029992083 修复弹窗内删除快捷键误删节点
排除 role=dialog 和 aria-modal 弹窗内的 Delete/Backspace 快捷键

补充弹窗焦点下节点不应被删除的回归测试

同步更新 UI 编辑器撤销重做规范
2026-09-04 18:04:16 +08:00
k88936 fe813802a5 简化工作流检查提示拼接
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m26s
Project CI / Native shell tests (pull_request) Successful in 20m41s
移除请检查文案探测逻辑
终态文案统一直接追加检查提示
2026-09-04 16:09:19 +08:00
k88936 7f3cf3b64b 调整工作流通知测试位置
将通知辅助测试纳入AGC测试目录
保持提示文案和步骤映射覆盖
2026-09-04 15:34:18 +08:00
k88936 e6b2539dfe 增加工作流通知文案测试
覆盖请检查提示去重
覆盖三项步骤标题映射
2026-09-04 15:32:48 +08:00
k88936 620c2c5015 接入UI工作流终态通知
为三项工作流动作增加成功失败通知
扩展结果数量与请检查提示
在编辑器页面挂载阻塞通知弹窗
2026-09-04 15:31:54 +08:00
k88936 e7345508df 提取UI工作流通知文案工具
拆分通知类型与文案辅助函数
修复组件快速刷新导出约束
2026-09-04 15:31:35 +08:00
k88936 fc37bfbceb 补充UI工作流完成通知设计
新增工作流终态通知弹窗交互约定
明确三步骤反馈文案与验收边界
2026-09-04 15:26:21 +08:00
k88936 20a065e540 同步 UI 编辑器快捷键规范
Project CI / Repository checks (pull_request) Failing after 16s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 2m47s
Project CI / Native shell tests (pull_request) Successful in 19m22s
记录 macOS Backspace 删除节点快捷键

明确按钮链接焦点下撤销重做与删除快捷键的差异
2026-09-04 13:00:04 +08:00
k88936 fb058adb17 明确删除后的节点选区判断
仅在存在选中节点时查询删除节点集合

避免使用空字符串作为节点 ID 查询值
2026-09-04 12:56:44 +08:00
k88936 4886dffda2 稳定 UI 编辑器删除回调引用
使用 useCallback 保持 deleteNode 在输入未变化时的函数引用稳定

减少全局键盘监听器不必要的解绑与重新注册
2026-09-04 12:56:13 +08:00
k88936 d456b97eaf 恢复工具栏聚焦时撤销快捷键
将删除快捷键的交互控件过滤与撤销重做的可编辑目标过滤分离

避免按钮和链接聚焦时意外屏蔽 Ctrl/Cmd+Z/Y
2026-09-04 12:55:00 +08:00
k88936 21aa894871 修复 UI 编辑器 macOS 删除快捷键
支持 macOS 标准 Delete 键上报的 Backspace 事件

保留原有修饰键、重复事件和交互控件过滤
2026-09-04 12:54:12 +08:00
k88936 2d884f1bc5 补充 Delete 快捷键页面验收测试
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 2m50s
Project CI / Native shell tests (pull_request) Successful in 18m50s
验证页面选中节点可通过 Delete 删除

覆盖真实树面板选择与删除后的渲染结果
2026-09-04 11:37:00 +08:00
k88936 5898d38083 支持 UI 编辑器 Delete 删除选中节点
复用现有 deleteNode 命令处理 Delete 快捷键

过滤交互控件、修饰键和重复按键事件

清理删除子树后的无效节点选区并补充页面测试
2026-09-04 11:35:58 +08:00
k88936 a2876eaf65 补充 UI 编辑器 Delete 快捷键规范
明确 Delete 快捷键触发范围与交互控件过滤

规定复用 deleteNode、子树删除、撤销与脏状态语义

补充删除后无效选区清理和验收标准
2026-09-04 11:28:56 +08:00
k88936 754964c57e 完善组件建议,增加容器背景与节点大小一致的实现
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Successful in 2m59s
Project CI / Native shell tests (pull_request) Failing after 11m40s
2026-09-04 11:03:09 +08:00
26 changed files with 1579 additions and 107 deletions
@@ -35,7 +35,9 @@ const SYSTEM_PROMPT: &str = r#"
* 对每个 Component,直接完整返回其全部参数. * 对每个 Component,直接完整返回其全部参数.
* 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。 * 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。
* 纯结构节点可以返回空数组并标为 NoProblem。 * 纯结构节点可以返回空数组并标为 NoProblem。
* 容器背景等推荐使用Simple + preserve_aspect: false 实现与node大小一致
* 面向用户的 reason 使用中文。 * 面向用户的 reason 使用中文。
"#; "#;
#[derive(Clone, Debug, Deserialize, JsonSchema)] #[derive(Clone, Debug, Deserialize, JsonSchema)]
@@ -14,3 +14,20 @@ export function collectUiNodeIds(root: UiNode): Set<NodeId> {
visitUiNodes(root, (node) => ids.add(node.id)); visitUiNodes(root, (node) => ids.add(node.id));
return ids; return ids;
} }
export function findUiNode(root: UiNode, nodeId: NodeId): UiNode | null {
if (root.id === nodeId) return root;
for (const child of root.children) {
const found = findUiNode(child, nodeId);
if (found) return found;
}
return null;
}
export function countUiNodeDescendants(root: UiNode): number {
let count = 0;
for (const child of root.children) {
count += 1 + countUiNodeDescendants(child);
}
return count;
}
@@ -0,0 +1,56 @@
import { ThemedModal } from '../../../components/modal/ThemedModal';
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
export type UiNodeDeleteRequest = {
nodeId: NodeId;
nodeLabel: string;
descendantCount: number;
};
type UiNodeDeleteConfirmModalProps = {
request: UiNodeDeleteRequest | null;
onCancel: () => void;
onConfirm: (nodeId: NodeId) => void;
disabled?: boolean;
};
export function UiNodeDeleteConfirmModal({
request,
onCancel,
onConfirm,
disabled = false,
}: UiNodeDeleteConfirmModalProps) {
return (
<ThemedModal
open={request !== null}
onClose={onCancel}
ariaLabel="确认删除节点"
panelClassName="w-[420px] rounded-2xl p-5"
>
<h2 className="m-0 text-base font-semibold"></h2>
<p className="text-sm leading-6 text-(--platform-text-soft)">
{request?.nodeLabel ?? ''} {request?.descendantCount ?? 0}{' '}
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
onClick={onCancel}
>
</button>
<button
type="button"
className="rounded-lg bg-red-600 px-3 py-2 text-xs font-semibold text-white"
disabled={disabled}
onClick={() => {
if (request) onConfirm(request.nodeId);
}}
>
</button>
</div>
</ThemedModal>
);
}
@@ -1,7 +1,10 @@
import { ChevronDown, Eye, EyeOff } from 'lucide-react'; import { ChevronDown, Eye, EyeOff, Puzzle, Trash2 } from 'lucide-react';
import { import {
forwardRef,
type HTMLAttributes,
type MouseEvent as ReactMouseEvent, type MouseEvent as ReactMouseEvent,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
@@ -9,16 +12,25 @@ import {
adjustMoveIndex, adjustMoveIndex,
type MoveHandler, type MoveHandler,
type NodeRendererProps, type NodeRendererProps,
type RowRendererProps,
Tree, Tree,
type TreeApi, type TreeApi,
} from 'react-arborist'; } from 'react-arborist';
import {
countUiNodeDescendants,
findUiNode,
} from '../../../features/ui-editor/treeUtils';
import type { Node as UiNode } from '../../../features/ui-editor/types/Node'; import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../features/ui-editor/types/NodeId'; import type { NodeId } from '../../../features/ui-editor/types/NodeId';
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId'; import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
import type { UiNodeMoveRequest } from '../../../features/ui-editor/types/UiNodeMoveRequest'; import type { UiNodeMoveRequest } from '../../../features/ui-editor/types/UiNodeMoveRequest';
import type { UiEditorNodeFocusRequest } from '../model'; import type { UiEditorNodeFocusRequest } from '../model';
import { UiNodeContextMenu } from './UiNodeContextMenu'; import { UiNodeContextMenu } from './UiNodeContextMenu';
import {
UiNodeDeleteConfirmModal,
type UiNodeDeleteRequest,
} from './UiNodeDeleteConfirmModal';
type UiTreePanelProps = { type UiTreePanelProps = {
root: UiNode | null; root: UiNode | null;
@@ -36,6 +48,38 @@ type UiTreePanelProps = {
className?: string; className?: string;
}; };
const TreeListOuter = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(function TreeListOuter({ style, ...props }, ref) {
return (
<div
{...props}
ref={ref}
style={{ ...style, overflowX: 'visible', overflowY: 'auto' }}
/>
);
});
function TreeRowContainer<T>({
node,
innerRef,
attrs,
children,
}: RowRendererProps<T>) {
return (
<div
{...attrs}
ref={innerRef}
style={{ ...attrs.style, minWidth: 0 }}
onFocus={(event) => event.stopPropagation()}
onClick={node.handleClick}
>
{children}
</div>
);
}
function TreeRow({ function TreeRow({
node, node,
style, style,
@@ -44,11 +88,17 @@ function TreeRow({
isNodeVisible, isNodeVisible,
onToggleNodeVisibility, onToggleNodeVisibility,
onOpenContextMenu, onOpenContextMenu,
onRequestDelete,
canDelete,
deleteDisabled,
}: NodeRendererProps<UiNode> & { }: NodeRendererProps<UiNode> & {
onSelectNode: (id: NodeId) => void; onSelectNode: (id: NodeId) => void;
isNodeVisible: (nodeId: NodeId) => boolean; isNodeVisible: (nodeId: NodeId) => boolean;
onToggleNodeVisibility: (id: NodeId) => void; onToggleNodeVisibility: (id: NodeId) => void;
onOpenContextMenu: (event: ReactMouseEvent, id: NodeId) => void; onOpenContextMenu: (event: ReactMouseEvent, id: NodeId) => void;
onRequestDelete: (id: NodeId) => void;
canDelete: boolean;
deleteDisabled: boolean;
}) { }) {
const data = node.data; const data = node.data;
const nodeLabel = data.metadata.name || '未命名节点'; const nodeLabel = data.metadata.name || '未命名节点';
@@ -56,8 +106,8 @@ function TreeRow({
return ( return (
<div <div
ref={dragHandle} ref={dragHandle}
style={style} style={{ ...style, width: '100%', height: '100%', minWidth: 0 }}
className={`flex min-w-0 items-center gap-1 rounded-lg px-2 text-xs text-(--platform-text-strong) ${ className={`relative flex min-w-0 items-center gap-1 rounded-lg px-2 pr-[68px] text-xs text-(--platform-text-strong) ${
node.isSelected ? 'bg-orange-100 text-orange-900' : 'hover:bg-black/5' node.isSelected ? 'bg-orange-100 text-orange-900' : 'hover:bg-black/5'
} ${isVisible ? '' : 'opacity-60'}`} } ${isVisible ? '' : 'opacity-60'}`}
onClick={() => { onClick={() => {
@@ -88,23 +138,53 @@ function TreeRow({
/> />
) : null} ) : null}
</button> </button>
<span className="min-w-0 flex-1 truncate">{nodeLabel}</span> <span className="shrink-0 whitespace-nowrap" title={nodeLabel}>
<span className="text-[10px] text-(--platform-text-soft)"> {nodeLabel}
{data.components.length}
</span> </span>
<button <span
type="button" className="inline-flex shrink-0 items-center gap-0.5 rounded-md bg-black/5 px-1 py-0.5 text-[10px] leading-none text-(--platform-text-soft)"
aria-label={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}`} title={`${data.components.length} 个组件`}
title={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}`} aria-label={`${data.components.length} 个组件`}
className="grid size-6 shrink-0 place-items-center rounded hover:bg-black/5"
onClick={(event) => {
event.stopPropagation();
onToggleNodeVisibility(data.id);
}}
onPointerDown={(event) => event.stopPropagation()}
> >
{isVisible ? <Eye size={13} /> : <EyeOff size={13} />} <Puzzle
</button> size={11}
className="text-orange-600"
fill="currentColor"
strokeWidth={1.5}
aria-hidden="true"
/>
<span>{data.components.length}</span>
</span>
<span className="absolute inset-y-0 right-2 flex w-[52px] items-center justify-end gap-1 bg-inherit">
<button
type="button"
aria-label={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}`}
title={`${isVisible ? '隐藏' : '显示'}节点“${nodeLabel}`}
className="grid size-6 shrink-0 place-items-center rounded hover:bg-black/5"
onClick={(event) => {
event.stopPropagation();
onToggleNodeVisibility(data.id);
}}
onPointerDown={(event) => event.stopPropagation()}
>
{isVisible ? <Eye size={13} /> : <EyeOff size={13} />}
</button>
<button
type="button"
aria-label={`删除节点“${nodeLabel}`}
title={canDelete ? `删除节点“${nodeLabel}` : '页面根节点不可删除'}
disabled={!canDelete || deleteDisabled}
aria-disabled={!canDelete || deleteDisabled}
className="grid size-6 shrink-0 place-items-center rounded text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-35"
onClick={(event) => {
event.stopPropagation();
onRequestDelete(data.id);
}}
onPointerDown={(event) => event.stopPropagation()}
>
<Trash2 size={13} />
</button>
</span>
</div> </div>
); );
} }
@@ -127,11 +207,15 @@ export function UiTreePanel({
const treeApiRef = useRef<TreeApi<UiNode> | undefined>(undefined); const treeApiRef = useRef<TreeApi<UiNode> | undefined>(undefined);
const treeContainerRef = useRef<HTMLDivElement>(null); const treeContainerRef = useRef<HTMLDivElement>(null);
const [treeHeight, setTreeHeight] = useState(0); const [treeHeight, setTreeHeight] = useState(0);
const [treeWidth, setTreeWidth] = useState(0);
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
nodeId: NodeId; nodeId: NodeId;
x: number; x: number;
y: number; y: number;
} | null>(null); } | null>(null);
const [pendingDelete, setPendingDelete] = useState<
(UiNodeDeleteRequest & { treeId: UIDesignImageId }) | null
>(null);
useEffect(() => { useEffect(() => {
if (!focusRequest) return; if (!focusRequest) return;
@@ -150,6 +234,41 @@ export function UiTreePanel({
return () => resizeObserver.disconnect(); return () => resizeObserver.disconnect();
}, []); }, []);
const treeRows = useMemo(() => {
if (!root) return [];
const rows: Array<{ node: UiNode; depth: number }> = [];
const visit = (node: UiNode, depth: number) => {
rows.push({ node, depth });
node.children.forEach((child) => visit(child, depth + 1));
};
visit(root, 0);
return rows;
}, [root]);
const treeContentWidth = useMemo(
() =>
treeRows.reduce((width, { node, depth }) => {
const labelWidth = Math.max(
56,
(node.metadata.name || '未命名节点').length * 8,
);
return Math.max(width, 16 + depth * 16 + 24 + labelWidth + 42 + 52);
}, 0),
[treeRows],
);
useEffect(() => {
const container = treeContainerRef.current;
if (!container) return;
const updateWidth = () => {
setTreeWidth(Math.max(treeContentWidth, container.clientWidth));
};
updateWidth();
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, [treeContentWidth]);
const handleMove: MoveHandler<UiNode> = ({ const handleMove: MoveHandler<UiNode> = ({
dragIds, dragIds,
parentId, parentId,
@@ -184,21 +303,55 @@ export function UiTreePanel({
const pageRootIds = new Set(root?.children.map((child) => child.id) ?? []); const pageRootIds = new Set(root?.children.map((child) => child.id) ?? []);
const contextTreeId = contextMenu ? treeIdForNode(contextMenu.nodeId) : null; const contextTreeId = contextMenu ? treeIdForNode(contextMenu.nodeId) : null;
const requestDelete = (nodeId: NodeId) => {
if (!root || isLocked || nodeId === root.id || pageRootIds.has(nodeId)) {
return;
}
const treeId = treeIdForNode(nodeId);
const node = findUiNode(root, nodeId);
if (!treeId || !node) return;
const descendantCount = countUiNodeDescendants(node);
if (descendantCount === 0) {
onDeleteNode(treeId, nodeId);
return;
}
setPendingDelete({
treeId,
nodeId,
nodeLabel: node.metadata.name || '未命名节点',
descendantCount,
});
};
const confirmDelete = (nodeId: NodeId) => {
if (!pendingDelete || isLocked || pendingDelete.nodeId !== nodeId) {
return;
}
const { treeId } = pendingDelete;
setPendingDelete(null);
onDeleteNode(treeId, nodeId);
};
return ( return (
<div <div
className={`relative flex h-full min-h-0 flex-col ${className ?? ''}`} className={`relative flex h-full min-h-0 flex-col ${className ?? ''}`}
onContextMenu={(event) => event.preventDefault()} onContextMenu={(event) => event.preventDefault()}
> >
<div ref={treeContainerRef} className="min-h-0 flex-1"> <div
ref={treeContainerRef}
className="relative min-h-0 flex-1 overflow-x-auto overflow-y-hidden"
>
{root && treeHeight > 0 ? ( {root && treeHeight > 0 ? (
<Tree<UiNode> <Tree<UiNode>
ref={treeApiRef} ref={treeApiRef}
data={[root]} data={[root]}
width="100%" width={treeWidth > 0 ? treeWidth : '100%'}
outerElementType={TreeListOuter}
height={treeHeight} height={treeHeight}
rowHeight={34} rowHeight={34}
indent={16} indent={16}
openByDefault openByDefault
renderRow={TreeRowContainer}
selection={selectedNodeId ?? undefined} selection={selectedNodeId ?? undefined}
onSelect={(nodes) => { onSelect={(nodes) => {
const selected = nodes[0]?.data; const selected = nodes[0]?.data;
@@ -224,6 +377,12 @@ export function UiTreePanel({
}} }}
isNodeVisible={isNodePreviewVisible} isNodeVisible={isNodePreviewVisible}
onToggleNodeVisibility={onToggleNodeVisibility} onToggleNodeVisibility={onToggleNodeVisibility}
onRequestDelete={requestDelete}
canDelete={
props.node.data.id !== root.id &&
!pageRootIds.has(props.node.data.id)
}
deleteDisabled={isLocked}
onOpenContextMenu={(event, nodeId) => onOpenContextMenu={(event, nodeId) =>
nodeId === root.id nodeId === root.id
? undefined ? undefined
@@ -252,9 +411,15 @@ export function UiTreePanel({
onClose={() => setContextMenu(null)} onClose={() => setContextMenu(null)}
onInsertChild={(nodeId) => onInsertNode(contextTreeId, nodeId)} onInsertChild={(nodeId) => onInsertNode(contextTreeId, nodeId)}
onInsertSibling={(nodeId) => onInsertNodeAfter(contextTreeId, nodeId)} onInsertSibling={(nodeId) => onInsertNodeAfter(contextTreeId, nodeId)}
onDelete={(nodeId) => onDeleteNode(contextTreeId, nodeId)} onDelete={requestDelete}
/> />
) : null} ) : null}
<UiNodeDeleteConfirmModal
request={pendingDelete}
onCancel={() => setPendingDelete(null)}
onConfirm={confirmDelete}
disabled={isLocked}
/>
</div> </div>
); );
} }
@@ -0,0 +1,40 @@
import { ThemedModal } from '../../../components/modal/ThemedModal';
import type { WorkflowCompletionNotice } from './workflowCompletionNotice';
import { workflowStepLabel } from './workflowCompletionNotice';
export function WorkflowCompletionModal({
notice,
onClose,
}: {
notice: WorkflowCompletionNotice | null;
onClose: () => void;
}) {
if (!notice) return null;
const stepLabel = workflowStepLabel(notice.step);
const outcomeLabel = notice.outcome === 'success' ? '完成' : '失败';
return (
<ThemedModal
open
onClose={onClose}
ariaLabel={`${stepLabel}${outcomeLabel}`}
panelClassName="w-[min(440px,calc(100vw-2rem))] rounded-2xl p-5"
>
<h2 className="m-0 text-base font-semibold">
{stepLabel}
{outcomeLabel}
</h2>
<p className="mt-3 text-sm leading-6 text-(--platform-text-soft)">
{notice.message}
</p>
<div className="mt-5 flex justify-end">
<button
type="button"
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
onClick={onClose}
>
</button>
</div>
</ThemedModal>
);
}
@@ -1,4 +1,6 @@
import { import {
CANVAS_ZOOM_IN_FACTOR,
CANVAS_ZOOM_OUT_FACTOR,
type CanvasViewport, type CanvasViewport,
createPanDragState, createPanDragState,
type DragState, type DragState,
@@ -28,8 +30,14 @@ import type { Node } from '../../../../features/ui-editor/types/Node';
import type { NodeId } from '../../../../features/ui-editor/types/NodeId'; import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
import type { UiEditorCanvasProjection } from '../../useUiEditorPage'; import type { UiEditorCanvasProjection } from '../../useUiEditorPage';
import { UiNodeContextMenu } from '../UiNodeContextMenu'; import { UiNodeContextMenu } from '../UiNodeContextMenu';
import {
handlePreviewZoomKeyDown,
isPreviewZoomInteractiveTarget,
previewZoomUsesMetaModifier,
} from './previewZoomKeyboard';
import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer'; import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer';
import { useNodeTransformInteraction } from './useNodeTransformInteraction'; import { useNodeTransformInteraction } from './useNodeTransformInteraction';
import { ZoomPercentageInput } from './ZoomPercentageInput';
export function PreviewWorkspace({ export function PreviewWorkspace({
canvas, canvas,
@@ -41,6 +49,8 @@ export function PreviewWorkspace({
const viewportRef = useRef<CanvasViewport>({ x: 0, y: 0, scale: 0.5 }); const viewportRef = useRef<CanvasViewport>({ x: 0, y: 0, scale: 0.5 });
const handledFocusRequestIdRef = useRef<number | null>(null); const handledFocusRequestIdRef = useRef<number | null>(null);
const panRef = useRef<Extract<DragState, { kind: 'pan' }> | null>(null); const panRef = useRef<Extract<DragState, { kind: 'pan' }> | null>(null);
const previewFocusedRef = useRef(false);
const previewHoveredRef = useRef(false);
const [viewport, setViewportState] = useState<CanvasViewport>( const [viewport, setViewportState] = useState<CanvasViewport>(
viewportRef.current, viewportRef.current,
); );
@@ -150,6 +160,34 @@ export function PreviewWorkspace({
); );
}, [logicalSize, setViewport]); }, [logicalSize, setViewport]);
const scaleViewportFromCenter = useCallback(
(nextScale: number) => {
const element = viewportElementRef.current;
const width = element?.clientWidth || canvasSize.width;
const height = element?.clientHeight || canvasSize.height;
setViewport(
scaleViewportFromScreenPoint({
viewport: viewportRef.current,
nextScale,
screenPoint: { x: width / 2, y: height / 2 },
}),
);
},
[canvasSize.height, canvasSize.width, setViewport],
);
const resetToActualSize = useCallback(() => {
scaleViewportFromCenter(1);
}, [scaleViewportFromCenter]);
const zoomIn = useCallback(() => {
scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_IN_FACTOR);
}, [scaleViewportFromCenter]);
const zoomOut = useCallback(() => {
scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_OUT_FACTOR);
}, [scaleViewportFromCenter]);
useEffect(() => { useEffect(() => {
const element = viewportElementRef.current; const element = viewportElementRef.current;
if (!element) return; if (!element) return;
@@ -224,6 +262,9 @@ export function PreviewWorkspace({
), ),
); );
}; };
const usesMetaModifier = previewZoomUsesMetaModifier(
window.navigator.platform,
);
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if ( if (
event.code === 'Space' && event.code === 'Space' &&
@@ -232,23 +273,21 @@ export function PreviewWorkspace({
) { ) {
setSpaceHeld(true); setSpaceHeld(true);
} }
if (!event.ctrlKey && !event.metaKey) return; handlePreviewZoomKeyDown(
if (event.key === '0') { event,
event.preventDefault(); {
fitToCanvas(); hasZoomableViewport: logicalSize !== null,
} else if (event.key === '1' && logicalSize) { isFocused: previewFocusedRef.current,
event.preventDefault(); isHovered: previewHoveredRef.current,
const element = viewportElementRef.current; usesMetaModifier,
const width = element?.clientWidth ?? 900; },
const height = element?.clientHeight ?? 640; {
setViewport( fit: fitToCanvas,
scaleViewportFromScreenPoint({ resetToActualSize,
viewport: viewportRef.current, zoomIn,
nextScale: 1, zoomOut,
screenPoint: { x: width / 2, y: height / 2 }, },
}), );
);
}
}; };
const onKeyUp = (event: KeyboardEvent) => { const onKeyUp = (event: KeyboardEvent) => {
if (event.code === 'Space') setSpaceHeld(false); if (event.code === 'Space') setSpaceHeld(false);
@@ -262,9 +301,12 @@ export function PreviewWorkspace({
window.removeEventListener('keyup', onKeyUp); window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onWindowBlur); window.removeEventListener('blur', onWindowBlur);
}; };
}, [fitToCanvas, logicalSize, setViewport]); }, [fitToCanvas, logicalSize, resetToActualSize, zoomIn, zoomOut]);
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => { const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button === 0 && !isPreviewZoomInteractiveTarget(event.target)) {
event.currentTarget.focus({ preventScroll: true });
}
if (event.button === 1 || (event.button === 0 && spaceHeld)) { if (event.button === 1 || (event.button === 0 && spaceHeld)) {
event.preventDefault(); event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
@@ -371,7 +413,27 @@ export function PreviewWorkspace({
</div> </div>
<SharedCanvasViewport <SharedCanvasViewport
ref={viewportElementRef} ref={viewportElementRef}
className="size-full" className="size-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-orange-400"
role="region"
aria-label="UI 预览画布"
tabIndex={0}
onPointerEnter={() => {
previewHoveredRef.current = true;
}}
onPointerLeave={() => {
previewHoveredRef.current = false;
}}
onFocus={() => {
previewFocusedRef.current = true;
}}
onBlur={(event) => {
if (
!(event.relatedTarget instanceof Node) ||
!event.currentTarget.contains(event.relatedTarget)
) {
previewFocusedRef.current = false;
}
}}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
@@ -448,21 +510,20 @@ export function PreviewWorkspace({
<ZoomControls <ZoomControls
viewport={viewport} viewport={viewport}
onFit={fitToCanvas} onFit={fitToCanvas}
onScaleFromCenter={(nextScale) => { onScaleFromCenter={scaleViewportFromCenter}
const element = viewportElementRef.current;
const width = element?.clientWidth ?? canvasSize.width;
const height = element?.clientHeight ?? canvasSize.height;
setViewport(
scaleViewportFromScreenPoint({
viewport: viewportRef.current,
nextScale,
screenPoint: { x: width / 2, y: height / 2 },
}),
);
}}
> >
{(actions) => ( {(actions) => (
<div className="absolute bottom-3 right-3 z-10 flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) bg-white/90 p-1 shadow-lg"> <div
className="absolute bottom-3 right-3 z-10 flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) bg-white/90 p-1 shadow-lg"
onMouseDown={(event) => {
if (
event.target instanceof Element &&
event.target.closest('button')
) {
event.preventDefault();
}
}}
>
<button <button
type="button" type="button"
className="grid size-8 place-items-center rounded hover:bg-black/5" className="grid size-8 place-items-center rounded hover:bg-black/5"
@@ -471,13 +532,33 @@ export function PreviewWorkspace({
> >
<Minus size={14} /> <Minus size={14} />
</button> </button>
<input
type="range"
min={25}
max={200}
step={5}
value={Math.round(
Number.parseInt(actions.displayPercent, 10),
)}
aria-label="画布缩放"
className="h-1 w-28 accent-orange-500"
onChange={(event) =>
actions.zoomToDisplayScale(Number(event.target.value) / 100)
}
/>
<ZoomPercentageInput
displayPercent={actions.displayPercent}
onCommit={(percent) =>
actions.zoomToDisplayScale(percent / 100)
}
/>
<button <button
type="button" type="button"
className="min-w-14 rounded px-2 py-1 text-[10px] tabular-nums hover:bg-black/5" className="rounded px-2 py-1 text-[10px] hover:bg-black/5"
aria-label={`适配画布,当前 ${actions.displayPercent}`} aria-label="适配画布"
onClick={actions.fit} onClick={actions.fit}
> >
{actions.displayPercent}
</button> </button>
<button <button
type="button" type="button"
@@ -90,7 +90,7 @@ function RenderNode({
selectedNodeId, selectedNodeId,
resources, resources,
onSelectNode, onSelectNode,
consumeNodeClick = () => false, consumeNodeClick,
onNodeContextMenu, onNodeContextMenu,
onNodePointerDown, onNodePointerDown,
onNodePointerMove, onNodePointerMove,
@@ -107,6 +107,8 @@ function RenderNode({
isRoot?: boolean; isRoot?: boolean;
parentContainer?: UiNode['layout']['container']; parentContainer?: UiNode['layout']['container'];
}) { }) {
const hasClickGestureConsumer = consumeNodeClick !== undefined;
const consumeClick = consumeNodeClick ?? (() => false);
const activePreviewTransforms = previewTransforms ?? EMPTY_PREVIEW_TRANSFORMS; const activePreviewTransforms = previewTransforms ?? EMPTY_PREVIEW_TRANSFORMS;
if (hiddenNodeIds.has(node.id)) return null; if (hiddenNodeIds.has(node.id)) return null;
const previewTransform = activePreviewTransforms.get(node.id); const previewTransform = activePreviewTransforms.get(node.id);
@@ -158,11 +160,12 @@ function RenderNode({
hasDirectPointerGesture hasDirectPointerGesture
? (event) => { ? (event) => {
event.stopPropagation(); event.stopPropagation();
consumeNodeClick(); if (consumeClick()) return;
if (hasClickGestureConsumer) onSelectNode(node.id);
} }
: (event) => { : (event) => {
event.stopPropagation(); event.stopPropagation();
if (consumeNodeClick()) return; if (consumeClick()) return;
onSelectNode(node.id); onSelectNode(node.id);
} }
} }
@@ -0,0 +1,79 @@
import { useEffect, useRef, useState } from 'react';
const MIN_ZOOM_PERCENT = 25;
const MAX_ZOOM_PERCENT = 200;
function displayPercentToValue(displayPercent: string) {
return displayPercent.replace('%', '');
}
export function ZoomPercentageInput({
displayPercent,
onCommit,
}: {
displayPercent: string;
onCommit: (percent: number) => void;
}) {
const [draft, setDraft] = useState(() =>
displayPercentToValue(displayPercent),
);
const draftRef = useRef(draft);
const isEditingRef = useRef(false);
useEffect(() => {
if (!isEditingRef.current) {
setDraft(displayPercentToValue(displayPercent));
draftRef.current = displayPercentToValue(displayPercent);
}
}, [displayPercent]);
const commit = () => {
const currentPercent = Number.parseFloat(
displayPercentToValue(displayPercent),
);
const parsed = Number.parseFloat(draftRef.current);
const nextPercent = Number.isFinite(parsed)
? Math.min(MAX_ZOOM_PERCENT, Math.max(MIN_ZOOM_PERCENT, parsed))
: currentPercent;
setDraft(String(nextPercent));
draftRef.current = String(nextPercent);
isEditingRef.current = false;
if (nextPercent !== currentPercent) {
onCommit(nextPercent);
}
};
return (
<label className="flex min-w-14 items-center justify-center rounded px-1 py-1 hover:bg-black/5">
<input
type="number"
min={MIN_ZOOM_PERCENT}
max={MAX_ZOOM_PERCENT}
step={5}
inputMode="numeric"
value={draft}
aria-label={`画布缩放,当前 ${displayPercent}`}
className="w-10 bg-transparent text-center text-[10px] tabular-nums outline-none"
onFocus={() => {
isEditingRef.current = true;
const nextDraft = displayPercentToValue(displayPercent);
setDraft(nextDraft);
draftRef.current = nextDraft;
}}
onChange={(event) => {
draftRef.current = event.target.value;
setDraft(event.target.value);
}}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
}}
/>
<span aria-hidden="true" className="text-[10px] tabular-nums">
%
</span>
</label>
);
}
@@ -0,0 +1,100 @@
export type PreviewZoomShortcut =
| 'fit'
| 'actual-size'
| 'zoom-in'
| 'zoom-out';
export type PreviewZoomKeyboardContext = {
hasZoomableViewport: boolean;
isFocused: boolean;
isHovered: boolean;
usesMetaModifier: boolean;
};
export type PreviewZoomKeyboardActions = {
fit: () => void;
resetToActualSize: () => void;
zoomIn: () => void;
zoomOut: () => void;
};
const INTERACTIVE_TARGET_SELECTOR = [
'button',
'a[href]',
'input',
'textarea',
'select',
'summary',
'[contenteditable]:not([contenteditable="false"])',
'[role="button"]',
'[role="link"]',
'[role="textbox"]',
'[role="combobox"]',
'[role="slider"]',
'[role="spinbutton"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="switch"]',
'[role="tab"]',
'[role="menuitem"]',
'[role="option"]',
'[role="dialog"]',
'[aria-modal="true"]',
].join(', ');
export function previewZoomUsesMetaModifier(platform: string) {
return /Mac|iPhone|iPad|iPod/i.test(platform);
}
export function isPreviewZoomInteractiveTarget(target: EventTarget | null) {
const element = target instanceof Element ? target : null;
return Boolean(element?.closest(INTERACTIVE_TARGET_SELECTOR));
}
function hasPlatformModifier(event: KeyboardEvent, usesMetaModifier: boolean) {
return usesMetaModifier
? event.metaKey && !event.ctrlKey
: event.ctrlKey && !event.metaKey;
}
export function resolvePreviewZoomShortcut(
event: KeyboardEvent,
): PreviewZoomShortcut | null {
if (event.altKey) return null;
if (event.key === '0') return 'fit';
if (event.key === '1') return 'actual-size';
if (event.code === 'NumpadAdd' || event.key === '+' || event.key === '=') {
return 'zoom-in';
}
if (event.code === 'NumpadSubtract' || event.key === '-') {
return 'zoom-out';
}
return null;
}
export function handlePreviewZoomKeyDown(
event: KeyboardEvent,
context: PreviewZoomKeyboardContext,
actions: PreviewZoomKeyboardActions,
) {
if (
event.defaultPrevented ||
!context.hasZoomableViewport ||
(!context.isHovered && !context.isFocused) ||
!hasPlatformModifier(event, context.usesMetaModifier) ||
isPreviewZoomInteractiveTarget(event.target)
) {
return false;
}
const shortcut = resolvePreviewZoomShortcut(event);
if (!shortcut) return false;
event.preventDefault();
event.stopPropagation();
if (shortcut === 'fit') actions.fit();
else if (shortcut === 'actual-size') actions.resetToActualSize();
else if (shortcut === 'zoom-in') actions.zoomIn();
else actions.zoomOut();
return true;
}
@@ -0,0 +1,26 @@
import type { UiEditorStepId } from '../model';
export type WorkflowCompletionNotice = {
step: UiEditorStepId;
outcome: 'success' | 'failure';
message: string;
};
export function appendWorkflowCheckPrompt(message: string): string {
const trimmed = message.trim();
if (!trimmed) return '请检查。';
return `${trimmed.replace(/[!?]+$/u, '')}`;
}
export function workflowStepLabel(step: UiEditorStepId): string {
switch (step) {
case 'reference-analysis':
return '分析参考图';
case 'structure-recognition':
return '识别界面结构';
case 'visual-binding':
return '绑定视觉素材';
}
const exhaustiveCheck: never = step;
return exhaustiveCheck;
}
@@ -16,7 +16,9 @@ import { PreviewWorkspace } from './components/preview/PreviewWorkspace';
import { RecognitionOverview } from './components/RecognitionOverview'; import { RecognitionOverview } from './components/RecognitionOverview';
import { ToolNavigation } from './components/ToolNavigation'; import { ToolNavigation } from './components/ToolNavigation';
import { WorkflowActionCard } from './components/WorkflowActionCard'; import { WorkflowActionCard } from './components/WorkflowActionCard';
import { WorkflowCompletionModal } from './components/WorkflowCompletionModal';
import { UI_EDITOR_STEPS, type UiEditorStepId } from './model'; import { UI_EDITOR_STEPS, type UiEditorStepId } from './model';
import { handleUiEditorKeyDown } from './uiEditorKeyboardShortcuts';
import { import {
type UiEditorWorkflowProjection, type UiEditorWorkflowProjection,
useUiEditorSession, useUiEditorSession,
@@ -79,41 +81,26 @@ export default function UiEditorPage({
session.save.isLocked; session.save.isLocked;
const historyUndo = session.history.undo; const historyUndo = session.history.undo;
const historyRedo = session.history.redo; const historyRedo = session.history.redo;
const selectedNodeId = session.input.selectedNodeId;
const activeImageId = session.input.activeImageId;
const deleteNode = session.input.deleteNode;
useEffect(() => { useEffect(() => {
if (session.save.isDirty) setGenerateSuccess(null); if (session.save.isDirty) setGenerateSuccess(null);
}, [session.save.isDirty]); }, [session.save.isDirty]);
useEffect(() => { useEffect(() => {
const isEditableTarget = (target: EventTarget | null) => { const onKeyDown = (event: KeyboardEvent) =>
const element = target instanceof HTMLElement ? target : null; handleUiEditorKeyDown(event, {
return Boolean( selectedNodeId,
element?.isContentEditable || activeImageId,
element?.closest('input, textarea, select, [contenteditable="true"]'), deleteNode,
); historyUndo,
}; historyRedo,
const onKeyDown = (event: KeyboardEvent) => { });
if (
event.repeat ||
event.defaultPrevented ||
isEditableTarget(event.target) ||
(!event.ctrlKey && !event.metaKey)
) {
return;
}
const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey;
const isRedo =
(event.key.toLowerCase() === 'z' && event.shiftKey) ||
(event.ctrlKey && event.key.toLowerCase() === 'y');
if (isUndo && historyUndo()) {
event.preventDefault();
} else if (isRedo && historyRedo()) {
event.preventDefault();
}
};
window.addEventListener('keydown', onKeyDown); window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown);
}, [historyRedo, historyUndo]); }, [activeImageId, deleteNode, historyRedo, historyUndo, selectedNodeId]);
async function save(afterReturn = false) { async function save(afterReturn = false) {
if (await session.save.save()) { if (await session.save.save()) {
@@ -298,6 +285,10 @@ export default function UiEditorPage({
</div> </div>
</div> </div>
<EditorDialogs dialogs={session.dialogs} /> <EditorDialogs dialogs={session.dialogs} />
<WorkflowCompletionModal
notice={session.workflow.completionNotice}
onClose={session.workflow.dismissCompletionNotice}
/>
<ThemedModal <ThemedModal
open={returnConfirmOpen} open={returnConfirmOpen}
onClose={() => setReturnConfirmOpen(false)} onClose={() => setReturnConfirmOpen(false)}
@@ -0,0 +1,82 @@
import type { NodeId } from '../../features/ui-editor/types/NodeId';
import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId';
type DeleteResult = { ok: boolean } | undefined;
export type UiEditorKeyboardActions = {
selectedNodeId: NodeId | null;
activeImageId: UIDesignImageId | null;
deleteNode: (nodeId: NodeId, treeId: UIDesignImageId) => DeleteResult;
historyUndo: () => boolean;
historyRedo: () => boolean;
};
function isEditableTarget(target: EventTarget | null) {
const element = target instanceof HTMLElement ? target : null;
return Boolean(
element?.isContentEditable ||
element?.closest('input, textarea, select, [contenteditable="true"]'),
);
}
function isInteractiveTarget(target: EventTarget | null) {
const element = target instanceof Element ? target : null;
return Boolean(
(target instanceof HTMLElement && target.isContentEditable) ||
element?.closest(
'button, a, input, textarea, select, [contenteditable="true"], [role="button"], [role="dialog"], [aria-modal="true"]',
),
);
}
function isModalTarget(target: EventTarget | null) {
const element = target instanceof Element ? target : null;
return Boolean(element?.closest('[role="dialog"], [aria-modal="true"]'));
}
export function handleUiEditorKeyDown(
event: KeyboardEvent,
actions: UiEditorKeyboardActions,
) {
if (isModalTarget(event.target)) return;
if (
(event.key === 'Delete' || event.key === 'Backspace') &&
!event.repeat &&
!event.defaultPrevented &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey &&
!isInteractiveTarget(event.target)
) {
if (actions.selectedNodeId && actions.activeImageId) {
const result = actions.deleteNode(
actions.selectedNodeId,
actions.activeImageId,
);
if (result?.ok) {
event.preventDefault();
event.stopPropagation();
return;
}
}
}
if (
event.repeat ||
event.defaultPrevented ||
isEditableTarget(event.target) ||
(!event.ctrlKey && !event.metaKey)
) {
return;
}
const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey;
const isRedo =
(event.key.toLowerCase() === 'z' && event.shiftKey) ||
(event.ctrlKey && event.key.toLowerCase() === 'y');
if (isUndo && actions.historyUndo()) {
event.preventDefault();
} else if (isRedo && actions.historyRedo()) {
event.preventDefault();
}
}
@@ -10,7 +10,10 @@ import {
} from '../../features/ui-editor/importAdapter'; } from '../../features/ui-editor/importAdapter';
import { applyMergeResult } from '../../features/ui-editor/merge'; import { applyMergeResult } from '../../features/ui-editor/merge';
import { applyRecognitionResult } from '../../features/ui-editor/recognition'; import { applyRecognitionResult } from '../../features/ui-editor/recognition';
import type { StageStatusField } from '../../features/ui-editor/stageStatusOverview'; import {
getStageStatusOverview,
type StageStatusField,
} from '../../features/ui-editor/stageStatusOverview';
import { collectUiNodeIds } from '../../features/ui-editor/treeUtils'; import { collectUiNodeIds } from '../../features/ui-editor/treeUtils';
import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO'; import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO';
import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode'; import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode';
@@ -54,6 +57,10 @@ import {
prerequisiteIssuesForStep, prerequisiteIssuesForStep,
type UiEditorPrerequisiteIssue, type UiEditorPrerequisiteIssue,
} from './components/WorkflowChecks'; } from './components/WorkflowChecks';
import {
appendWorkflowCheckPrompt,
type WorkflowCompletionNotice,
} from './components/workflowCompletionNotice';
import { import {
type PendingResourceRemoval, type PendingResourceRemoval,
removalHasDownstreamReferences, removalHasDownstreamReferences,
@@ -175,6 +182,8 @@ export function useUiEditorSession(
initialFurthestStepIndex = 0, initialFurthestStepIndex = 0,
) { ) {
const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE); const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE);
const editorDeleteNode = editor.deleteNode;
const editorUiTrees = editor.state.ui_trees;
const replaceEditorState = editor.replaceState; const replaceEditorState = editor.replaceState;
const [isLoading, setIsLoading] = useState(Boolean(resourceId)); const [isLoading, setIsLoading] = useState(Boolean(resourceId));
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
@@ -246,6 +255,19 @@ export function useUiEditorSession(
const [hasSuggested, setHasSuggested] = useState(false); const [hasSuggested, setHasSuggested] = useState(false);
const [hasRecognized, setHasRecognized] = useState(false); const [hasRecognized, setHasRecognized] = useState(false);
const [hasBound, setHasBound] = useState(false); const [hasBound, setHasBound] = useState(false);
const [completionNotice, setCompletionNotice] =
useState<WorkflowCompletionNotice | null>(null);
function reportWorkflowCompletion(
step: UiEditorStepId,
outcome: WorkflowCompletionNotice['outcome'],
rawMessage: string,
setStatus: (message: string) => void,
) {
const message = appendWorkflowCheckPrompt(rawMessage);
setStatus(message);
setCompletionNotice({ step, outcome, message });
}
useEffect(() => { useEffect(() => {
setActiveStep(initialStep); setActiveStep(initialStep);
@@ -437,6 +459,9 @@ export function useUiEditorSession(
); );
return next.size === current.size ? current : next; return next.size === current.size ? current : next;
}); });
setSelectedNodeId((current) =>
current && !validNodeIds.has(current) ? null : current,
);
}, [editor.state.ui_trees]); }, [editor.state.ui_trees]);
const isNodePreviewVisible = useCallback( const isNodePreviewVisible = useCallback(
@@ -879,16 +904,26 @@ export function useUiEditorSession(
return result; return result;
} }
function deleteNode(nodeId: NodeId, treeId = activeImageId) { const deleteNode = useCallback(
if (!treeId) return; (nodeId: NodeId, treeId = activeImageId) => {
const result = editor.deleteNode(treeId, nodeId); if (!treeId) return;
if (!result.ok) { const tree = editorUiTrees.find(
setStatus('无法删除该节点。'); (candidate) => candidate.src_ui_design === treeId,
);
const location = tree ? findUiNodeLocation(tree.root, nodeId) : null;
const deletedNodeIds = location ? collectUiNodeIds(location.node) : null;
const result = editorDeleteNode(treeId, nodeId);
if (!result.ok) {
setStatus('无法删除该节点。');
return result;
}
if (selectedNodeId !== null && deletedNodeIds?.has(selectedNodeId)) {
setSelectedNodeId(null);
}
return result; return result;
} },
if (selectedNodeId === nodeId) setSelectedNodeId(null); [activeImageId, editorDeleteNode, editorUiTrees, selectedNodeId],
return result; );
}
function selectSprite(id: SpriteAssetId) { function selectSprite(id: SpriteAssetId) {
setSelectedNodeId(null); setSelectedNodeId(null);
@@ -939,6 +974,7 @@ export function useUiEditorSession(
async function suggestUiDesignSemantics() { async function suggestUiDesignSemantics() {
if (isSuggesting || isWorkflowBusy) return; if (isSuggesting || isWorkflowBusy) return;
setCompletionNotice(null);
setSuggestionStatus(null); setSuggestionStatus(null);
setIsSuggesting(true); setIsSuggesting(true);
try { try {
@@ -949,11 +985,19 @@ export function useUiEditorSession(
); );
editor.replaceState(applyUiDesignSuggestions(snapshot, suggestions)); editor.replaceState(applyUiDesignSuggestions(snapshot, suggestions));
setHasSuggested(true); setHasSuggested(true);
setSuggestionStatus(`已应用 ${suggestions.length} 条参考图语义建议。`); reportWorkflowCompletion(
'reference-analysis',
'success',
`参考图分析完成:已应用 ${suggestions.length} 条参考图语义建议`,
setSuggestionStatus,
);
}); });
} catch (cause) { } catch (cause) {
setSuggestionStatus( reportWorkflowCompletion(
'reference-analysis',
'failure',
cause instanceof Error ? cause.message : String(cause), cause instanceof Error ? cause.message : String(cause),
setSuggestionStatus,
); );
} finally { } finally {
setIsSuggesting(false); setIsSuggesting(false);
@@ -962,6 +1006,7 @@ export function useUiEditorSession(
async function recognizeUi() { async function recognizeUi() {
if (isRecognizing || isWorkflowBusy) return; if (isRecognizing || isWorkflowBusy) return;
setCompletionNotice(null);
setRecognitionStatus(null); setRecognitionStatus(null);
setIsRecognizing(true); setIsRecognizing(true);
try { try {
@@ -970,14 +1015,27 @@ export function useUiEditorSession(
projectPath, projectPath,
state: snapshot, state: snapshot,
}); });
editor.replaceState(applyRecognitionResult(snapshot, result)); const nextState = applyRecognitionResult(snapshot, result);
editor.replaceState(nextState);
setHasRecognized(true); setHasRecognized(true);
setSelectedNodeId(null); setSelectedNodeId(null);
setRecognitionStatus(`已替换 ${result.ui_trees.length} 棵界面树。`); const overview = getStageStatusOverview(
nextState.ui_trees,
'layout_status',
);
reportWorkflowCompletion(
'structure-recognition',
'success',
`界面结构识别完成:已替换 ${result.ui_trees.length} 棵界面树,待检查 ${overview.needsAttention} 项(必须修复 ${overview.blocked} 项)`,
setRecognitionStatus,
);
}); });
} catch (cause) { } catch (cause) {
setRecognitionStatus( reportWorkflowCompletion(
'structure-recognition',
'failure',
cause instanceof Error ? cause.message : String(cause), cause instanceof Error ? cause.message : String(cause),
setRecognitionStatus,
); );
} finally { } finally {
setIsRecognizing(false); setIsRecognizing(false);
@@ -1005,6 +1063,7 @@ export function useUiEditorSession(
async function bindComponents() { async function bindComponents() {
if (isBinding || isWorkflowBusy) return; if (isBinding || isWorkflowBusy) return;
setCompletionNotice(null);
setBindingStatus(null); setBindingStatus(null);
setIsBinding(true); setIsBinding(true);
try { try {
@@ -1032,13 +1091,21 @@ export function useUiEditorSession(
history: index < batches.length - 1 ? 'skip' : 'record', history: index < batches.length - 1 ? 'skip' : 'record',
}); });
} }
setBindingStatus( reportWorkflowCompletion(
`组件绑定完成(${batches.length}/${batches.length})。`, 'visual-binding',
'success',
`视觉素材绑定完成:已处理 ${batches.length}/${batches.length} 个批次`,
setBindingStatus,
); );
setHasBound(true); setHasBound(true);
}); });
} catch (cause) { } catch (cause) {
setBindingStatus(cause instanceof Error ? cause.message : String(cause)); reportWorkflowCompletion(
'visual-binding',
'failure',
cause instanceof Error ? cause.message : String(cause),
setBindingStatus,
);
} finally { } finally {
setIsBinding(false); setIsBinding(false);
} }
@@ -1303,6 +1370,7 @@ export function useUiEditorSession(
isBinding, isBinding,
hasBound, hasBound,
bindingStatus, bindingStatus,
completionNotice,
bindComponents, bindComponents,
requestStepChange, requestStepChange,
continueToNextStep: () => { continueToNextStep: () => {
@@ -1310,6 +1378,7 @@ export function useUiEditorSession(
}, },
confirmStepChange, confirmStepChange,
cancelStepChange: () => setPendingWorkflowStepChange(null), cancelStepChange: () => setPendingWorkflowStepChange(null),
dismissCompletionNotice: () => setCompletionNotice(null),
}, },
dialogs: { dialogs: {
projectPath, projectPath,
@@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ZoomPercentageInput } from '../src/view/ui-editor/components/preview/ZoomPercentageInput';
describe('ZoomPercentageInput', () => {
it('edits the displayed percentage and commits on blur without fitting', () => {
const onCommit = vi.fn();
render(<ZoomPercentageInput displayPercent="50%" onCommit={onCommit} />);
const input = screen.getByRole('spinbutton', { name: /画布缩放/ });
expect((input as HTMLInputElement).value).toBe('50');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '125' } });
expect(onCommit).not.toHaveBeenCalled();
fireEvent.blur(input);
expect(onCommit).toHaveBeenCalledWith(125);
expect((input as HTMLInputElement).value).toBe('125');
});
it.each([
{ value: '0', expected: 25 },
{ value: '999', expected: 200 },
])('clamps $value to $expected on blur', ({ value, expected }) => {
const onCommit = vi.fn();
render(<ZoomPercentageInput displayPercent="100%" onCommit={onCommit} />);
const input = screen.getByRole('spinbutton', { name: /画布缩放/ });
fireEvent.focus(input);
fireEvent.change(input, { target: { value } });
fireEvent.blur(input);
expect(onCommit).toHaveBeenCalledWith(expected);
expect((input as HTMLInputElement).value).toBe(String(expected));
});
it('restores the current percentage when the draft is invalid', () => {
const onCommit = vi.fn();
render(<ZoomPercentageInput displayPercent="80%" onCommit={onCommit} />);
const input = screen.getByRole('spinbutton', { name: /画布缩放/ });
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '' } });
fireEvent.blur(input);
expect(onCommit).not.toHaveBeenCalled();
expect((input as HTMLInputElement).value).toBe('80');
});
it('tracks viewport updates while not editing', () => {
const onCommit = vi.fn();
const view = render(
<ZoomPercentageInput displayPercent="80%" onCommit={onCommit} />,
);
const input = screen.getByRole('spinbutton', { name: /画布缩放/ });
view.rerender(
<ZoomPercentageInput displayPercent="140%" onCommit={onCommit} />,
);
expect((input as HTMLInputElement).value).toBe('140');
});
});
@@ -0,0 +1,209 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Node as UiNode } from '../src/features/ui-editor/types/Node';
import type { UIDesignImage } from '../src/features/ui-editor/types/UIDesignImage';
import { PreviewWorkspace } from '../src/view/ui-editor/components/preview/PreviewWorkspace';
import type { UiEditorCanvasProjection } from '../src/view/ui-editor/useUiEditorPage';
class TestResizeObserver {
observe() {}
disconnect() {}
unobserve() {}
}
const root: UiNode = {
id: 'root',
layout: {
transform: {
anchor_min: [0, 0],
anchor_max: [1, 1],
offset_min: [0, 0],
offset_max: [0, 0],
},
custom_minimum_size: [0, 0],
size_flags_horizontal: 1,
size_flags_vertical: 1,
size_flags_stretch_ratio: 1,
container: 'None',
},
metadata: {
name: '根节点',
description: '',
layout_status: 'NoProblem',
components_status: 'NoProblem',
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'System',
},
components: [],
children_display_mode: 'Stack',
children: [],
};
const activeImage: UIDesignImage = {
metadata: {
name: '测试界面',
description: '',
role: null,
slave_to: null,
},
path: 'assets/page.png',
pixel_size: [1200, 800],
pixels_per_unit: 1,
};
function createCanvas(
overrides: Partial<UiEditorCanvasProjection> = {},
): UiEditorCanvasProjection {
return {
isLocked: false,
activeImage,
activeImageId: 'page-1',
previewUrls: { 'page-1': 'data:image/png;base64,' },
images: { 'page-1': activeImage },
sprites: {},
fontFaces: {},
tree: { src_ui_design: 'page-1', root },
selectedNode: null,
selectedNodeId: null,
keepChildrenUnchanged: false,
hiddenNodeIds: new Set(),
focusRequest: null,
status: null,
isNodePreviewVisible: vi.fn(() => true),
toggleNodePreviewVisibility: vi.fn(),
selectExclusiveChild: vi.fn(),
selectNode: vi.fn(),
clearNodeSelection: vi.fn(),
updateNodeTransform: vi.fn(),
insertNode: vi.fn(),
insertNodeAfter: vi.fn(),
deleteNode: vi.fn(),
openClearDialog: vi.fn(),
...overrides,
};
}
function renderedScale(container: HTMLElement) {
const transform = (
container.querySelector('.genarrative-image-canvas__world') as HTMLElement
).style.transform;
const scale = /scale\(([^)]+)\)/.exec(transform)?.[1];
if (!scale) throw new Error(`无法从 ${transform} 读取缩放值`);
return Number(scale);
}
function previewZoomModifier(): KeyboardEventInit {
return /Mac|iPhone|iPad|iPod/i.test(window.navigator.platform)
? { metaKey: true }
: { ctrlKey: true };
}
beforeEach(() => {
vi.stubGlobal('ResizeObserver', TestResizeObserver);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('PreviewWorkspace quick zoom', () => {
it('uses the toolbar zoom step while the preview is hovered', () => {
const rendered = render(<PreviewWorkspace canvas={createCanvas()} />);
const preview = screen.getByRole('region', { name: 'UI 预览画布' });
const before = renderedScale(rendered.container);
fireEvent.pointerEnter(preview);
const wasNotCancelled = fireEvent.keyDown(window, {
key: '=',
code: 'Equal',
...previewZoomModifier(),
cancelable: true,
});
expect(wasNotCancelled).toBe(false);
expect(renderedScale(rendered.container)).toBeCloseTo(before * 1.16);
});
it('requires hover or focus and ignores interactive targets', () => {
const rendered = render(<PreviewWorkspace canvas={createCanvas()} />);
const preview = screen.getByRole('region', { name: 'UI 预览画布' });
const zoomButton = screen.getByRole('button', { name: '放大画布' });
const initial = renderedScale(rendered.container);
expect(
fireEvent.keyDown(window, {
key: '=',
...previewZoomModifier(),
cancelable: true,
}),
).toBe(true);
expect(renderedScale(rendered.container)).toBe(initial);
fireEvent.pointerEnter(preview);
expect(
fireEvent.keyDown(zoomButton, {
key: '=',
...previewZoomModifier(),
cancelable: true,
}),
).toBe(true);
expect(renderedScale(rendered.container)).toBe(initial);
fireEvent.pointerLeave(preview);
fireEvent.focus(preview);
fireEvent.keyDown(window, {
key: '-',
code: 'Minus',
...previewZoomModifier(),
cancelable: true,
});
expect(renderedScale(rendered.container)).toBeCloseTo(initial * 0.86);
});
it('keeps actual-size and fit shortcuts within the preview scope', () => {
const rendered = render(<PreviewWorkspace canvas={createCanvas()} />);
const preview = screen.getByRole('region', { name: 'UI 预览画布' });
const fitted = renderedScale(rendered.container);
fireEvent.focus(preview);
fireEvent.keyDown(window, {
key: '1',
...previewZoomModifier(),
cancelable: true,
});
expect(renderedScale(rendered.container)).toBe(1);
fireEvent.keyDown(window, {
key: '0',
...previewZoomModifier(),
cancelable: true,
});
expect(renderedScale(rendered.container)).toBe(fitted);
});
it('leaves browser zoom untouched when the preview has no content', () => {
render(
<PreviewWorkspace
canvas={createCanvas({
activeImage: null,
activeImageId: null,
tree: null,
})}
/>,
);
expect(
fireEvent.keyDown(window, {
key: '=',
...previewZoomModifier(),
cancelable: true,
}),
).toBe(true);
});
});
@@ -0,0 +1,176 @@
// @vitest-environment jsdom
import { fireEvent } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
handlePreviewZoomKeyDown,
type PreviewZoomKeyboardActions,
type PreviewZoomKeyboardContext,
previewZoomUsesMetaModifier,
} from '../src/view/ui-editor/components/preview/previewZoomKeyboard';
function createActions(): PreviewZoomKeyboardActions {
return {
fit: vi.fn(),
resetToActualSize: vi.fn(),
zoomIn: vi.fn(),
zoomOut: vi.fn(),
};
}
function dispatchShortcut({
actions = createActions(),
context = {},
event,
target,
}: {
actions?: PreviewZoomKeyboardActions;
context?: Partial<PreviewZoomKeyboardContext>;
event: KeyboardEventInit;
target?: HTMLElement;
}) {
const resolvedTarget = target ?? document.createElement('div');
document.body.append(resolvedTarget);
const handler = vi.fn((keyboardEvent: KeyboardEvent) =>
handlePreviewZoomKeyDown(
keyboardEvent,
{
hasZoomableViewport: true,
isFocused: false,
isHovered: true,
usesMetaModifier: false,
...context,
},
actions,
),
);
resolvedTarget.addEventListener('keydown', handler);
fireEvent.keyDown(resolvedTarget, event);
const keyboardEvent = handler.mock.calls[0]?.[0];
return { actions, handler, keyboardEvent };
}
afterEach(() => {
document.body.replaceChildren();
});
describe('preview zoom keyboard shortcuts', () => {
it.each([
{ key: '+', code: 'Equal' },
{ key: '=', code: 'Equal' },
{ key: '+', code: 'NumpadAdd' },
])('zooms in for the supported main and numpad keys (%o)', (event) => {
const { actions, keyboardEvent } = dispatchShortcut({
event: { ...event, ctrlKey: true, cancelable: true, repeat: true },
});
expect(actions.zoomIn).toHaveBeenCalledTimes(1);
expect(keyboardEvent?.defaultPrevented).toBe(true);
});
it.each([
{ key: '-', code: 'Minus' },
{ key: '-', code: 'NumpadSubtract' },
])('zooms out for the supported main and numpad keys (%o)', (event) => {
const { actions, keyboardEvent } = dispatchShortcut({
event: { ...event, ctrlKey: true, cancelable: true },
});
expect(actions.zoomOut).toHaveBeenCalledTimes(1);
expect(keyboardEvent?.defaultPrevented).toBe(true);
});
it('does not treat underscore as zoom out', () => {
const { actions, keyboardEvent } = dispatchShortcut({
event: {
key: '_',
code: 'Minus',
ctrlKey: true,
shiftKey: true,
cancelable: true,
},
});
expect(actions.zoomOut).not.toHaveBeenCalled();
expect(keyboardEvent?.defaultPrevented).toBe(false);
});
it.each([
{ key: '0', action: 'fit' as const },
{ key: '1', action: 'resetToActualSize' as const },
])('keeps the existing $key shortcut', ({ key, action }) => {
const { actions } = dispatchShortcut({
event: { key, ctrlKey: true, cancelable: true },
});
expect(actions[action]).toHaveBeenCalledTimes(1);
});
it('uses Cmd on Apple platforms and Ctrl elsewhere', () => {
expect(previewZoomUsesMetaModifier('MacIntel')).toBe(true);
expect(previewZoomUsesMetaModifier('iPad')).toBe(true);
expect(previewZoomUsesMetaModifier('Win32')).toBe(false);
const appleActions = createActions();
dispatchShortcut({
actions: appleActions,
context: { usesMetaModifier: true },
event: { key: '=', ctrlKey: true, cancelable: true },
});
expect(appleActions.zoomIn).not.toHaveBeenCalled();
dispatchShortcut({
actions: appleActions,
context: { usesMetaModifier: true },
event: { key: '=', metaKey: true, cancelable: true },
});
expect(appleActions.zoomIn).toHaveBeenCalledTimes(1);
});
it.each([
{ isHovered: false, isFocused: false, hasZoomableViewport: true },
{ isHovered: true, isFocused: false, hasZoomableViewport: false },
])('leaves inactive preview shortcuts to the host (%o)', (context) => {
const { actions, keyboardEvent } = dispatchShortcut({
context,
event: { key: '=', ctrlKey: true, cancelable: true },
});
expect(actions.zoomIn).not.toHaveBeenCalled();
expect(keyboardEvent?.defaultPrevented).toBe(false);
});
it('works while the preview is focused without being hovered', () => {
const { actions } = dispatchShortcut({
context: { isFocused: true, isHovered: false },
event: { key: '=', ctrlKey: true, cancelable: true },
});
expect(actions.zoomIn).toHaveBeenCalledTimes(1);
});
it.each(['input', 'button', 'a'])('ignores interactive %s targets', (tag) => {
const target = document.createElement(tag);
if (target instanceof HTMLAnchorElement) target.href = '#preview';
const { actions, keyboardEvent } = dispatchShortcut({
target,
event: { key: '=', ctrlKey: true, cancelable: true },
});
expect(actions.zoomIn).not.toHaveBeenCalled();
expect(keyboardEvent?.defaultPrevented).toBe(false);
});
it('does not override an event already handled by another control', () => {
const target = document.createElement('div');
target.addEventListener('keydown', (event) => event.preventDefault(), {
once: true,
});
const { actions } = dispatchShortcut({
target,
event: { key: '=', ctrlKey: true, cancelable: true },
});
expect(actions.zoomIn).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import { fireEvent } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { handleUiEditorKeyDown } from '../src/view/ui-editor/uiEditorKeyboardShortcuts';
describe('ui editor keyboard shortcuts', () => {
afterEach(() => {
document.body.replaceChildren();
});
it.each([
{ key: 'z', ctrlKey: true },
{ key: 'z', ctrlKey: true, shiftKey: true },
{ key: 'y', ctrlKey: true },
])('does not change history from inside a modal (%o)', (shortcut) => {
const dialog = document.createElement('div');
dialog.setAttribute('role', 'dialog');
const button = document.createElement('button');
dialog.append(button);
document.body.append(dialog);
const historyUndo = vi.fn(() => true);
const historyRedo = vi.fn(() => true);
const listener = (event: KeyboardEvent) =>
handleUiEditorKeyDown(event, {
selectedNodeId: null,
activeImageId: null,
deleteNode: vi.fn(),
historyUndo,
historyRedo,
});
window.addEventListener('keydown', listener);
fireEvent.keyDown(button, shortcut);
expect(historyUndo).not.toHaveBeenCalled();
expect(historyRedo).not.toHaveBeenCalled();
window.removeEventListener('keydown', listener);
});
it('keeps undo available from a non-modal button', () => {
const button = document.createElement('button');
document.body.append(button);
const historyUndo = vi.fn(() => true);
const historyRedo = vi.fn(() => true);
const listener = (event: KeyboardEvent) =>
handleUiEditorKeyDown(event, {
selectedNodeId: null,
activeImageId: null,
deleteNode: vi.fn(),
historyUndo,
historyRedo,
});
window.addEventListener('keydown', listener);
fireEvent.keyDown(button, { key: 'z', ctrlKey: true });
expect(historyUndo).toHaveBeenCalledTimes(1);
window.removeEventListener('keydown', listener);
});
it.each(['Delete', 'Backspace'])(
'does not delete a node when the zoom slider has focus (%s)',
(key) => {
const slider = document.createElement('input');
slider.type = 'range';
document.body.append(slider);
const deleteNode = vi.fn(() => ({ ok: true }));
const listener = (event: KeyboardEvent) =>
handleUiEditorKeyDown(event, {
selectedNodeId: 'node-1',
activeImageId: 'page-1',
deleteNode,
historyUndo: vi.fn(() => true),
historyRedo: vi.fn(() => true),
});
window.addEventListener('keydown', listener);
fireEvent.keyDown(slider, { key });
expect(deleteNode).not.toHaveBeenCalled();
window.removeEventListener('keydown', listener);
},
);
it('keeps node deletion available after zoom button focus is released', () => {
const zoomControls = document.createElement('div');
const zoomIn = document.createElement('button');
zoomControls.append(zoomIn);
document.body.append(zoomControls);
const deleteNode = vi.fn(() => ({ ok: true }));
const listener = (event: KeyboardEvent) =>
handleUiEditorKeyDown(event, {
selectedNodeId: 'node-1',
activeImageId: 'page-1',
deleteNode,
historyUndo: vi.fn(() => true),
historyRedo: vi.fn(() => true),
});
window.addEventListener('keydown', listener);
fireEvent.click(zoomIn);
fireEvent.keyDown(window, { key: 'Delete' });
expect(deleteNode).toHaveBeenCalledWith('node-1', 'page-1');
window.removeEventListener('keydown', listener);
});
});
@@ -473,6 +473,75 @@ describe('UiEditorPage', () => {
expect(result.current.canvas.hiddenNodeIds.size).toBe(0); expect(result.current.canvas.hiddenNodeIds.size).toBe(0);
}); });
it('clears selection when deleting a node removes the selected descendant', async () => {
const { result } = await renderLoadedSession(stateWithPages(['page']));
const rootId = 'page-root';
let parentId: string | undefined;
let childId: string | undefined;
act(() => {
parentId = result.current.input.insertNode(rootId, 'page')?.value;
childId = result.current.input.insertNode(parentId!, 'page')?.value;
result.current.input.selectNode(childId!);
});
act(() => result.current.input.deleteNode(parentId!, 'page'));
expect(result.current.canvas.selectedNodeId).toBeNull();
expect(result.current.history.canUndo).toBe(true);
});
it('deletes the selected node from the page with Delete', async () => {
const state = stateWithPages(['page']);
state.ui_trees[0]!.root.children = [node('page-child')];
const stateStore: IUiDesignStateStore = {
load: vi.fn().mockResolvedValue({ revision: 0, state }),
save: vi.fn(),
generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')),
};
render(
createElement(UiEditorPage, {
projectPath: '/tmp/ui-editor-delete-keyboard',
resourceId: 'ui-resource',
stateStore,
}),
);
const child = await screen.findByText('page-child');
fireEvent.click(child);
fireEvent.keyDown(window, { key: 'Delete' });
await waitFor(() => expect(screen.queryByText('page-child')).toBeNull());
});
it('does not delete a selected node when Delete originates inside a dialog', async () => {
const state = stateWithPages(['page']);
state.ui_trees[0]!.root.children = [node('page-child')];
const stateStore: IUiDesignStateStore = {
load: vi.fn().mockResolvedValue({ revision: 0, state }),
save: vi.fn(),
generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')),
};
render(
createElement(UiEditorPage, {
projectPath: '/tmp/ui-editor-delete-dialog',
resourceId: 'ui-resource',
stateStore,
}),
);
const child = await screen.findByText('page-child');
fireEvent.click(child);
const dialog = document.createElement('div');
dialog.setAttribute('role', 'dialog');
document.body.appendChild(dialog);
fireEvent.keyDown(dialog, { key: 'Delete' });
expect(screen.queryAllByText('page-child').length).toBeGreaterThan(0);
dialog.remove();
});
it('keeps Inspector status highlighting separate from node navigation', async () => { it('keeps Inspector status highlighting separate from node navigation', async () => {
const { result } = await renderLoadedSession(stateWithPages(['page'])); const { result } = await renderLoadedSession(stateWithPages(['page']));
const rootId = 'page-root'; const rootId = 'page-root';
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import {
countUiNodeDescendants,
findUiNode,
} from '../src/features/ui-editor/treeUtils';
import type { Node } from '../src/features/ui-editor/types/Node';
function node(id: string, children: Node[] = []): Node {
return {
id,
layout: {} as Node['layout'],
metadata: {} as Node['metadata'],
components: [],
children_display_mode: 'Stack',
children,
};
}
describe('ui tree utilities', () => {
it('finds a nested node and counts all descendants', () => {
const nested = node('nested', [node('leaf')]);
const root = node('root', [node('page'), nested]);
expect(findUiNode(root, 'nested')).toBe(nested);
expect(findUiNode(root, 'missing')).toBeNull();
expect(countUiNodeDescendants(nested)).toBe(1);
expect(countUiNodeDescendants(root)).toBe(3);
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import {
appendWorkflowCheckPrompt,
workflowStepLabel,
} from '../src/view/ui-editor/components/workflowCompletionNotice';
describe('workflow completion notice helpers', () => {
it('appends the review prompt to a terminal status', () => {
expect(appendWorkflowCheckPrompt('已应用 3 条建议。')).toBe(
'已应用 3 条建议,请检查。',
);
});
it('maps every workflow step to a user-facing label', () => {
expect(workflowStepLabel('reference-analysis')).toBe('分析参考图');
expect(workflowStepLabel('structure-recognition')).toBe('识别界面结构');
expect(workflowStepLabel('visual-binding')).toBe('绑定视觉素材');
});
});
@@ -58,6 +58,10 @@ Runtime 确认卡与普通聊天确认卡必须共用“信息区 + 固定操作
## 2026-08-19 UI Editor 节点右键菜单 ## 2026-08-19 UI Editor 节点右键菜单
## 2026-09-05 UI Editor 节点树快捷删除
左侧 `UI Tree` 每个可删除节点行在右侧提供桌面端快捷删除按钮,眼睛与删除按钮组成固定宽度的右侧动作列;整棵树保留横向滚动,深层节点的缩进、完整名称和组件数随树内容一起滚动,不使用省略号截断。沿用右键菜单的页面根节点禁删规则。快捷删除与右键删除共用页面级 `requestDelete` 入口:叶子节点直接调用现有删除命令;包含后代节点时先打开模态确认,正文显示节点名称及将同时删除的后代节点数量,按钮为“取消 / 删除”。确认期间使用现有 `ThemedModal` 的模态行为,取消或完成后关闭弹窗。底层 `deleteNode` 命令继续保持无确认,以兼容键盘 `Delete` 及已有状态测试;锁定态下快捷按钮和右键删除均不可执行。
## 2026-08-20 UI Editor 最终预览互斥子节点 ## 2026-08-20 UI Editor 最终预览互斥子节点
最终预览中,选中一个 `Exclusive` 父节点时,它的子节点切换条必须在该父节点自身的预览坐标空间内、紧贴节点上方悬浮;不得固定在预览容器左上角,也不得另行按屏幕坐标换算。点击 tab 必须显式选中对应子节点,不能按通用“切换可见性”语义把当前分支隐藏。`Exclusive` 父节点首次加载且尚未发生可见性操作时,必须默认且仅显示第一个直接子节点;用户手动隐藏全部直接子节点后必须保持全部隐藏,不得再次回退显示第一个子节点;空容器不显示子节点。切换条始终按内容宽度展开并允许溢出节点边界,不设最大宽度或内部滚动区域。从 `Exclusive` 切回 `Stack` 时必须清除全部直接子节点因互斥选择产生的隐藏状态并立即显示所有子节点,后代节点自身的独立隐藏状态保持不变。切换条仅改变现有子节点可见性状态,不能触发参考图重读、视口重新适配或预览树的异步重建。 最终预览中,选中一个 `Exclusive` 父节点时,它的子节点切换条必须在该父节点自身的预览坐标空间内、紧贴节点上方悬浮;不得固定在预览容器左上角,也不得另行按屏幕坐标换算。点击 tab 必须显式选中对应子节点,不能按通用“切换可见性”语义把当前分支隐藏。`Exclusive` 父节点首次加载且尚未发生可见性操作时,必须默认且仅显示第一个直接子节点;用户手动隐藏全部直接子节点后必须保持全部隐藏,不得再次回退显示第一个子节点;空容器不显示子节点。切换条始终按内容宽度展开并允许溢出节点边界,不设最大宽度或内部滚动区域。从 `Exclusive` 切回 `Stack` 时必须清除全部直接子节点因互斥选择产生的隐藏状态并立即显示所有子节点,后代节点自身的独立隐藏状态保持不变。切换条仅改变现有子节点可见性状态,不能触发参考图重读、视口重新适配或预览树的异步重建。
@@ -0,0 +1,40 @@
# UI 编辑器工作流完成通知弹窗
## 目标
UI 编辑器的“分析参考图”“识别界面结构”“绑定视觉素材”三个工作流动作在每次运行结束后,用独立的阻塞通知弹窗明确反馈结果,避免仅依赖卡片内一行状态文本而被忽略。
## 交互约定
- 三个动作的每次运行在终态(成功或失败)时自动弹出一次通知。
- 弹窗打开期间遮挡并阻塞工作台底层交互;关闭后恢复当前步骤,不自动切换步骤、不自动重跑。
- 使用现有 `ThemedModal` 的普通关闭行为(遮罩、Esc 和关闭按钮均可关闭)。
- 弹窗仅承载通知,不提供“继续”“重试”或其他业务操作。
- 关闭弹窗后,工作流卡片继续显示同一条结果状态;重新运行产生新的终态时再次通知。
- 绑定动作包含多个批次时,只在最终批次结束后通知一次。
## 文案
弹窗标题由步骤名和结果态组成,例如“识别界面结构完成”或“绑定视觉素材失败”。正文复用卡片状态文本,并逐条扩展结果信息,统一以“请检查”收尾。
成功状态的基线文案:
- 分析参考图:保留已应用的语义建议数量;若现有状态可可靠取得问题/待确认数量,则一并展示。
- 识别界面结构:保留替换的界面树数量,并展示识别结果中的待检查/必须修复数量(若可取得)。
- 绑定视觉素材:保留现有 `B/B` 批次计数,改为用户可读的绑定结果。
失败状态保留实际错误文本,仅在弹窗标题中补充步骤和失败上下文,正文同样以“请检查”收尾。
## 实现边界
- 新增独立的工作流通知弹窗组件文件,组件只负责展示和关闭,不包含工作流领域规则或后端副作用。
- 在 UI 编辑器页面/会话投影中维护临时通知状态,并在三个异步动作的成功与失败终态写入。
- 不新增后端字段或公开契约;数量只能使用当前前端已有且可靠的数据。
## 验收
1. 三个动作成功和失败终态各弹出一次通知;绑定批次只弹最终一次。
2. 弹窗打开时底层工作台不可操作,且无继续/重试等业务按钮。
3. 弹窗可通过标准关闭方式退出;关闭后卡片状态仍可见。
4. 每条成功文案保留原有数量信息并增加可用的检查数量,所有文案包含“请检查”。
5. 重新运行后新的终态会再次弹出通知。
@@ -48,13 +48,19 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被
- 桌面端页面工具栏提供撤销和重做按钮。 - 桌面端页面工具栏提供撤销和重做按钮。
- 非文本编辑目标聚焦编辑器时支持 `Cmd/Ctrl+Z` 撤销、`Cmd/Ctrl+Shift+Z``Ctrl+Y` 重做。 - 非文本编辑目标聚焦编辑器时支持 `Cmd/Ctrl+Z` 撤销、`Cmd/Ctrl+Shift+Z``Ctrl+Y` 重做。
- `input``textarea``select``contenteditable` 以及按钮/链接等控件交给浏览器原生行为,不拦截文本撤销。 - `input``textarea``select``contenteditable`文本编辑控件交给浏览器原生行为,不拦截文本撤销;按钮/链接等非文本控件仍允许编辑器撤销/重做快捷键生效
- UI Editor 页面内选中节点后,支持不带修饰键的 `Delete` 删除节点及其子节点;快捷键复用 Inspector、树面板和右键菜单共用的 `deleteNode` 命令,因此沿用根节点/锁定禁删、单条历史记录、dirty 标记和后续保存语义。
- `Delete` / `Backspace``input``textarea``select``contenteditable`、按钮和链接等交互控件聚焦时交给浏览器原生行为;无选中、根节点、锁定或目标不存在时不执行删除。长按重复事件不重复删除,成功处理后阻止默认行为和事件冒泡。
- `Delete` / `Backspace` 在打开的对话框(`role="dialog"``aria-modal="true"`)内聚焦时交给对话框处理,不删除对话框背后的节点。
- `Cmd/Ctrl+Z``Cmd/Ctrl+Shift+Z``Ctrl+Y` 在打开的对话框(`role="dialog"``aria-modal="true"`)内聚焦时暂停编辑器级撤销/重做,由对话框或浏览器原生行为处理。
- 无可撤销或重做记录时按钮禁用,并提供可访问名称。 - 无可撤销或重做记录时按钮禁用,并提供可访问名称。
## 脏状态与选择 ## 脏状态与选择
撤销和重做恢复的 State 继续参与现有 dirty 判定、保存和后端持久化。历史快照不包含当前设计图、节点选择、隐藏集合或视口;恢复后若当前选择已不存在,页面清理无效选择并保持安全空态。 撤销和重做恢复的 State 继续参与现有 dirty 判定、保存和后端持久化。历史快照不包含当前设计图、节点选择、隐藏集合或视口;恢复后若当前选择已不存在,页面清理无效选择并保持安全空态。
删除节点后,若当前选中节点是被删除节点或其任意后代,页面将选区清空为 `null`;删除成功不强制抢占焦点。该选择清理属于 UI 临时状态,不写入撤销历史。
## 验收标准 ## 验收标准
1. 单次字段编辑可撤销和重做。 1. 单次字段编辑可撤销和重做。
@@ -67,3 +73,4 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被
8. 工具栏按钮、禁用态和桌面快捷键可用,文本控件保留原生撤销。 8. 工具栏按钮、禁用态和桌面快捷键可用,文本控件保留原生撤销。
9. no-op、锁定、校验失败和不存在目标不进入历史。 9. no-op、锁定、校验失败和不存在目标不进入历史。
10. 颜色选择器和九宫格边界拖动不会按每个 pointer move 写入 State。 10. 颜色选择器和九宫格边界拖动不会按每个 pointer move 写入 State。
11. `Delete` / `Backspace` 仅在非交互控件聚焦且存在可删除选中节点时生效;删除整棵子树、可撤销、长按只处理一次,并清理被删除子树内的无效选区。
@@ -0,0 +1,26 @@
# 预览画布缩放滑杆
## 交付目标
将 UI 编辑器预览区现有的缩放按钮替换为桌面端可拖动滑杆,同时保留快速缩小、放大和适配画布操作。
## 交互约定
- 滑杆选择范围为 25%–200%,步进 5%;快捷加减继续使用核心视口的现有缩放边界。
- 滑杆位于预览区右下角固定工具栏;不提供移动端或窄窗口 fallback。
- 保留 ```+` 按钮。点击轨道可跳转,拖动滑块实时更新画布和百分比。
- 缩放以视口中心为中心,继续支持 Ctrl/Cmd + 滚轮缩放。
- 滑杆支持方向键、Home、End;百分比文本可点击编辑并在失焦时限制到有效范围。
- 百分比文本使用数字输入框呈现;输入提交后同步视口缩放,适配画布保持独立按钮,点击百分比不会触发适配。
- 缩放滑杆获得焦点时,Delete/Backspace 不触发 UI 节点快捷删除;缩放按钮鼠标点击不改变快捷键焦点。
- Windows/Linux 使用 `Ctrl`macOS 使用 `Cmd``+``=``NumpadAdd` 放大,`-``NumpadSubtract` 缩小,允许按键重复时连续缩放。
- 快捷键直接复用现有 `+`/`` 按钮动作:以视口中心为锚点,按 `×1.16`/`×0.86` 改变缩放。
- 鼠标悬停在预览画布上,或预览画布已获得键盘焦点时,快捷键生效。预览画布需提供可聚焦语义与无障碍名称。
- 事件来自输入框、文本编辑区、按钮、链接、缩放工具栏或其他可操作控件时不拦截;不向 iframe 子文档注入监听。
- 仅在存在可缩放视口且确认命中快捷键时调用 `preventDefault()``stopPropagation()`,防止浏览器页面同时缩放。
- 保留现有 `Ctrl/Cmd+0` 适配画布与 `Ctrl/Cmd+1` 恢复 100% 行为,不增加其他重置快捷键。缩放仍是当前预览实例的临时 UI 状态。
- 保留快捷键与缩放焦点边界的组件级回归测试,不扩展端到端测试。
## 验收
桌面端可通过按钮、轨道点击、拖动和键盘改变缩放;百分比与画布同步,边界不会越界。组件级测试覆盖平台修饰键、主键盘与小键盘变体、按键重复、悬停/焦点作用域、可编辑与可操作目标放行、无内容放行、浏览器默认行为拦截和现有 `0`/`1` 回归;另运行前端定向类型检查、编码检查和 `git diff --check`
+2
View File
@@ -4,6 +4,8 @@ export const CANVAS_WORLD_SIZE = 12000;
export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2; export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2;
export const MIN_SCALE = 0.025; export const MIN_SCALE = 0.025;
export const MAX_SCALE = 3.2; export const MAX_SCALE = 3.2;
export const CANVAS_ZOOM_IN_FACTOR = 1.16;
export const CANVAS_ZOOM_OUT_FACTOR = 0.86;
export const CANVAS_DISPLAY_SCALE_BASE = 0.5; export const CANVAS_DISPLAY_SCALE_BASE = 0.5;
export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
export const FIT_VIEW_PADDING = 10; export const FIT_VIEW_PADDING = 10;

Some files were not shown because too many files have changed in this diff Show More