UI编辑器一批细节优化 (#278)
* 提示词:完善组件建议,增加容器背景与节点大小一致的实现 * 支持Delete删除节点 ctrl + -缩放 * 步骤结束弹窗提醒   * UI 树快捷删除  * zoom滑动条样式 before:  after:  Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/278 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
This commit was merged in pull request #278.
This commit is contained in:
@@ -35,7 +35,9 @@ const SYSTEM_PROMPT: &str = r#"
|
||||
* 对每个 Component,直接完整返回其全部参数.
|
||||
* 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。
|
||||
* 纯结构节点可以返回空数组并标为 NoProblem。
|
||||
* 容器背景等推荐使用Simple + preserve_aspect: false 实现与node大小一致
|
||||
* 面向用户的 reason 使用中文。
|
||||
|
||||
"#;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
|
||||
@@ -14,3 +14,20 @@ export function collectUiNodeIds(root: UiNode): Set<NodeId> {
|
||||
visitUiNodes(root, (node) => ids.add(node.id));
|
||||
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 {
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -9,16 +12,25 @@ import {
|
||||
adjustMoveIndex,
|
||||
type MoveHandler,
|
||||
type NodeRendererProps,
|
||||
type RowRendererProps,
|
||||
Tree,
|
||||
type TreeApi,
|
||||
} from 'react-arborist';
|
||||
|
||||
import {
|
||||
countUiNodeDescendants,
|
||||
findUiNode,
|
||||
} from '../../../features/ui-editor/treeUtils';
|
||||
import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
|
||||
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
|
||||
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
|
||||
import type { UiNodeMoveRequest } from '../../../features/ui-editor/types/UiNodeMoveRequest';
|
||||
import type { UiEditorNodeFocusRequest } from '../model';
|
||||
import { UiNodeContextMenu } from './UiNodeContextMenu';
|
||||
import {
|
||||
UiNodeDeleteConfirmModal,
|
||||
type UiNodeDeleteRequest,
|
||||
} from './UiNodeDeleteConfirmModal';
|
||||
|
||||
type UiTreePanelProps = {
|
||||
root: UiNode | null;
|
||||
@@ -36,6 +48,38 @@ type UiTreePanelProps = {
|
||||
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({
|
||||
node,
|
||||
style,
|
||||
@@ -44,11 +88,17 @@ function TreeRow({
|
||||
isNodeVisible,
|
||||
onToggleNodeVisibility,
|
||||
onOpenContextMenu,
|
||||
onRequestDelete,
|
||||
canDelete,
|
||||
deleteDisabled,
|
||||
}: NodeRendererProps<UiNode> & {
|
||||
onSelectNode: (id: NodeId) => void;
|
||||
isNodeVisible: (nodeId: NodeId) => boolean;
|
||||
onToggleNodeVisibility: (id: NodeId) => void;
|
||||
onOpenContextMenu: (event: ReactMouseEvent, id: NodeId) => void;
|
||||
onRequestDelete: (id: NodeId) => void;
|
||||
canDelete: boolean;
|
||||
deleteDisabled: boolean;
|
||||
}) {
|
||||
const data = node.data;
|
||||
const nodeLabel = data.metadata.name || '未命名节点';
|
||||
@@ -56,8 +106,8 @@ function TreeRow({
|
||||
return (
|
||||
<div
|
||||
ref={dragHandle}
|
||||
style={style}
|
||||
className={`flex min-w-0 items-center gap-1 rounded-lg px-2 text-xs text-(--platform-text-strong) ${
|
||||
style={{ ...style, width: '100%', height: '100%', minWidth: 0 }}
|
||||
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'
|
||||
} ${isVisible ? '' : 'opacity-60'}`}
|
||||
onClick={() => {
|
||||
@@ -88,23 +138,53 @@ function TreeRow({
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 truncate">{nodeLabel}</span>
|
||||
<span className="text-[10px] text-(--platform-text-soft)">
|
||||
{data.components.length}
|
||||
<span className="shrink-0 whitespace-nowrap" title={nodeLabel}>
|
||||
{nodeLabel}
|
||||
</span>
|
||||
<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()}
|
||||
<span
|
||||
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)"
|
||||
title={`${data.components.length} 个组件`}
|
||||
aria-label={`${data.components.length} 个组件`}
|
||||
>
|
||||
{isVisible ? <Eye size={13} /> : <EyeOff size={13} />}
|
||||
</button>
|
||||
<Puzzle
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -127,11 +207,15 @@ export function UiTreePanel({
|
||||
const treeApiRef = useRef<TreeApi<UiNode> | undefined>(undefined);
|
||||
const treeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [treeHeight, setTreeHeight] = useState(0);
|
||||
const [treeWidth, setTreeWidth] = useState(0);
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
nodeId: NodeId;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<
|
||||
(UiNodeDeleteRequest & { treeId: UIDesignImageId }) | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusRequest) return;
|
||||
@@ -150,6 +234,41 @@ export function UiTreePanel({
|
||||
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> = ({
|
||||
dragIds,
|
||||
parentId,
|
||||
@@ -184,21 +303,55 @@ export function UiTreePanel({
|
||||
const pageRootIds = new Set(root?.children.map((child) => child.id) ?? []);
|
||||
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 (
|
||||
<div
|
||||
className={`relative flex h-full min-h-0 flex-col ${className ?? ''}`}
|
||||
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 ? (
|
||||
<Tree<UiNode>
|
||||
ref={treeApiRef}
|
||||
data={[root]}
|
||||
width="100%"
|
||||
width={treeWidth > 0 ? treeWidth : '100%'}
|
||||
outerElementType={TreeListOuter}
|
||||
height={treeHeight}
|
||||
rowHeight={34}
|
||||
indent={16}
|
||||
openByDefault
|
||||
renderRow={TreeRowContainer}
|
||||
selection={selectedNodeId ?? undefined}
|
||||
onSelect={(nodes) => {
|
||||
const selected = nodes[0]?.data;
|
||||
@@ -224,6 +377,12 @@ export function UiTreePanel({
|
||||
}}
|
||||
isNodeVisible={isNodePreviewVisible}
|
||||
onToggleNodeVisibility={onToggleNodeVisibility}
|
||||
onRequestDelete={requestDelete}
|
||||
canDelete={
|
||||
props.node.data.id !== root.id &&
|
||||
!pageRootIds.has(props.node.data.id)
|
||||
}
|
||||
deleteDisabled={isLocked}
|
||||
onOpenContextMenu={(event, nodeId) =>
|
||||
nodeId === root.id
|
||||
? undefined
|
||||
@@ -252,9 +411,15 @@ export function UiTreePanel({
|
||||
onClose={() => setContextMenu(null)}
|
||||
onInsertChild={(nodeId) => onInsertNode(contextTreeId, nodeId)}
|
||||
onInsertSibling={(nodeId) => onInsertNodeAfter(contextTreeId, nodeId)}
|
||||
onDelete={(nodeId) => onDeleteNode(contextTreeId, nodeId)}
|
||||
onDelete={requestDelete}
|
||||
/>
|
||||
) : null}
|
||||
<UiNodeDeleteConfirmModal
|
||||
request={pendingDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={confirmDelete}
|
||||
disabled={isLocked}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
+116
-35
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
CANVAS_ZOOM_IN_FACTOR,
|
||||
CANVAS_ZOOM_OUT_FACTOR,
|
||||
type CanvasViewport,
|
||||
createPanDragState,
|
||||
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 { UiEditorCanvasProjection } from '../../useUiEditorPage';
|
||||
import { UiNodeContextMenu } from '../UiNodeContextMenu';
|
||||
import {
|
||||
handlePreviewZoomKeyDown,
|
||||
isPreviewZoomInteractiveTarget,
|
||||
previewZoomUsesMetaModifier,
|
||||
} from './previewZoomKeyboard';
|
||||
import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer';
|
||||
import { useNodeTransformInteraction } from './useNodeTransformInteraction';
|
||||
import { ZoomPercentageInput } from './ZoomPercentageInput';
|
||||
|
||||
export function PreviewWorkspace({
|
||||
canvas,
|
||||
@@ -41,6 +49,8 @@ export function PreviewWorkspace({
|
||||
const viewportRef = useRef<CanvasViewport>({ x: 0, y: 0, scale: 0.5 });
|
||||
const handledFocusRequestIdRef = useRef<number | null>(null);
|
||||
const panRef = useRef<Extract<DragState, { kind: 'pan' }> | null>(null);
|
||||
const previewFocusedRef = useRef(false);
|
||||
const previewHoveredRef = useRef(false);
|
||||
const [viewport, setViewportState] = useState<CanvasViewport>(
|
||||
viewportRef.current,
|
||||
);
|
||||
@@ -150,6 +160,34 @@ export function PreviewWorkspace({
|
||||
);
|
||||
}, [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(() => {
|
||||
const element = viewportElementRef.current;
|
||||
if (!element) return;
|
||||
@@ -224,6 +262,9 @@ export function PreviewWorkspace({
|
||||
),
|
||||
);
|
||||
};
|
||||
const usesMetaModifier = previewZoomUsesMetaModifier(
|
||||
window.navigator.platform,
|
||||
);
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.code === 'Space' &&
|
||||
@@ -232,23 +273,21 @@ export function PreviewWorkspace({
|
||||
) {
|
||||
setSpaceHeld(true);
|
||||
}
|
||||
if (!event.ctrlKey && !event.metaKey) return;
|
||||
if (event.key === '0') {
|
||||
event.preventDefault();
|
||||
fitToCanvas();
|
||||
} else if (event.key === '1' && logicalSize) {
|
||||
event.preventDefault();
|
||||
const element = viewportElementRef.current;
|
||||
const width = element?.clientWidth ?? 900;
|
||||
const height = element?.clientHeight ?? 640;
|
||||
setViewport(
|
||||
scaleViewportFromScreenPoint({
|
||||
viewport: viewportRef.current,
|
||||
nextScale: 1,
|
||||
screenPoint: { x: width / 2, y: height / 2 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
handlePreviewZoomKeyDown(
|
||||
event,
|
||||
{
|
||||
hasZoomableViewport: logicalSize !== null,
|
||||
isFocused: previewFocusedRef.current,
|
||||
isHovered: previewHoveredRef.current,
|
||||
usesMetaModifier,
|
||||
},
|
||||
{
|
||||
fit: fitToCanvas,
|
||||
resetToActualSize,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
},
|
||||
);
|
||||
};
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.code === 'Space') setSpaceHeld(false);
|
||||
@@ -262,9 +301,12 @@ export function PreviewWorkspace({
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', onWindowBlur);
|
||||
};
|
||||
}, [fitToCanvas, logicalSize, setViewport]);
|
||||
}, [fitToCanvas, logicalSize, resetToActualSize, zoomIn, zoomOut]);
|
||||
|
||||
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)) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
@@ -371,7 +413,27 @@ export function PreviewWorkspace({
|
||||
</div>
|
||||
<SharedCanvasViewport
|
||||
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}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
@@ -448,21 +510,20 @@ export function PreviewWorkspace({
|
||||
<ZoomControls
|
||||
viewport={viewport}
|
||||
onFit={fitToCanvas}
|
||||
onScaleFromCenter={(nextScale) => {
|
||||
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 },
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onScaleFromCenter={scaleViewportFromCenter}
|
||||
>
|
||||
{(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
|
||||
type="button"
|
||||
className="grid size-8 place-items-center rounded hover:bg-black/5"
|
||||
@@ -471,13 +532,33 @@ export function PreviewWorkspace({
|
||||
>
|
||||
<Minus size={14} />
|
||||
</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
|
||||
type="button"
|
||||
className="min-w-14 rounded px-2 py-1 text-[10px] tabular-nums hover:bg-black/5"
|
||||
aria-label={`适配画布,当前 ${actions.displayPercent}`}
|
||||
className="rounded px-2 py-1 text-[10px] hover:bg-black/5"
|
||||
aria-label="适配画布"
|
||||
onClick={actions.fit}
|
||||
>
|
||||
{actions.displayPercent}
|
||||
适配
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -90,7 +90,7 @@ function RenderNode({
|
||||
selectedNodeId,
|
||||
resources,
|
||||
onSelectNode,
|
||||
consumeNodeClick = () => false,
|
||||
consumeNodeClick,
|
||||
onNodeContextMenu,
|
||||
onNodePointerDown,
|
||||
onNodePointerMove,
|
||||
@@ -107,6 +107,8 @@ function RenderNode({
|
||||
isRoot?: boolean;
|
||||
parentContainer?: UiNode['layout']['container'];
|
||||
}) {
|
||||
const hasClickGestureConsumer = consumeNodeClick !== undefined;
|
||||
const consumeClick = consumeNodeClick ?? (() => false);
|
||||
const activePreviewTransforms = previewTransforms ?? EMPTY_PREVIEW_TRANSFORMS;
|
||||
if (hiddenNodeIds.has(node.id)) return null;
|
||||
const previewTransform = activePreviewTransforms.get(node.id);
|
||||
@@ -158,11 +160,12 @@ function RenderNode({
|
||||
hasDirectPointerGesture
|
||||
? (event) => {
|
||||
event.stopPropagation();
|
||||
consumeNodeClick();
|
||||
if (consumeClick()) return;
|
||||
if (hasClickGestureConsumer) onSelectNode(node.id);
|
||||
}
|
||||
: (event) => {
|
||||
event.stopPropagation();
|
||||
if (consumeNodeClick()) return;
|
||||
if (consumeClick()) return;
|
||||
onSelectNode(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
+79
@@ -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>
|
||||
);
|
||||
}
|
||||
+100
@@ -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 { ToolNavigation } from './components/ToolNavigation';
|
||||
import { WorkflowActionCard } from './components/WorkflowActionCard';
|
||||
import { WorkflowCompletionModal } from './components/WorkflowCompletionModal';
|
||||
import { UI_EDITOR_STEPS, type UiEditorStepId } from './model';
|
||||
import { handleUiEditorKeyDown } from './uiEditorKeyboardShortcuts';
|
||||
import {
|
||||
type UiEditorWorkflowProjection,
|
||||
useUiEditorSession,
|
||||
@@ -79,41 +81,26 @@ export default function UiEditorPage({
|
||||
session.save.isLocked;
|
||||
const historyUndo = session.history.undo;
|
||||
const historyRedo = session.history.redo;
|
||||
const selectedNodeId = session.input.selectedNodeId;
|
||||
const activeImageId = session.input.activeImageId;
|
||||
const deleteNode = session.input.deleteNode;
|
||||
|
||||
useEffect(() => {
|
||||
if (session.save.isDirty) setGenerateSuccess(null);
|
||||
}, [session.save.isDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
const isEditableTarget = (target: EventTarget | null) => {
|
||||
const element = target instanceof HTMLElement ? target : null;
|
||||
return Boolean(
|
||||
element?.isContentEditable ||
|
||||
element?.closest('input, textarea, select, [contenteditable="true"]'),
|
||||
);
|
||||
};
|
||||
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();
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) =>
|
||||
handleUiEditorKeyDown(event, {
|
||||
selectedNodeId,
|
||||
activeImageId,
|
||||
deleteNode,
|
||||
historyUndo,
|
||||
historyRedo,
|
||||
});
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [historyRedo, historyUndo]);
|
||||
}, [activeImageId, deleteNode, historyRedo, historyUndo, selectedNodeId]);
|
||||
|
||||
async function save(afterReturn = false) {
|
||||
if (await session.save.save()) {
|
||||
@@ -298,6 +285,10 @@ export default function UiEditorPage({
|
||||
</div>
|
||||
</div>
|
||||
<EditorDialogs dialogs={session.dialogs} />
|
||||
<WorkflowCompletionModal
|
||||
notice={session.workflow.completionNotice}
|
||||
onClose={session.workflow.dismissCompletionNotice}
|
||||
/>
|
||||
<ThemedModal
|
||||
open={returnConfirmOpen}
|
||||
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';
|
||||
import { applyMergeResult } from '../../features/ui-editor/merge';
|
||||
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 type { BindingDTO } from '../../features/ui-editor/types/BindingDTO';
|
||||
import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode';
|
||||
@@ -54,6 +57,10 @@ import {
|
||||
prerequisiteIssuesForStep,
|
||||
type UiEditorPrerequisiteIssue,
|
||||
} from './components/WorkflowChecks';
|
||||
import {
|
||||
appendWorkflowCheckPrompt,
|
||||
type WorkflowCompletionNotice,
|
||||
} from './components/workflowCompletionNotice';
|
||||
import {
|
||||
type PendingResourceRemoval,
|
||||
removalHasDownstreamReferences,
|
||||
@@ -175,6 +182,8 @@ export function useUiEditorSession(
|
||||
initialFurthestStepIndex = 0,
|
||||
) {
|
||||
const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE);
|
||||
const editorDeleteNode = editor.deleteNode;
|
||||
const editorUiTrees = editor.state.ui_trees;
|
||||
const replaceEditorState = editor.replaceState;
|
||||
const [isLoading, setIsLoading] = useState(Boolean(resourceId));
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
@@ -246,6 +255,19 @@ export function useUiEditorSession(
|
||||
const [hasSuggested, setHasSuggested] = useState(false);
|
||||
const [hasRecognized, setHasRecognized] = 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(() => {
|
||||
setActiveStep(initialStep);
|
||||
@@ -437,6 +459,9 @@ export function useUiEditorSession(
|
||||
);
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
setSelectedNodeId((current) =>
|
||||
current && !validNodeIds.has(current) ? null : current,
|
||||
);
|
||||
}, [editor.state.ui_trees]);
|
||||
|
||||
const isNodePreviewVisible = useCallback(
|
||||
@@ -879,16 +904,26 @@ export function useUiEditorSession(
|
||||
return result;
|
||||
}
|
||||
|
||||
function deleteNode(nodeId: NodeId, treeId = activeImageId) {
|
||||
if (!treeId) return;
|
||||
const result = editor.deleteNode(treeId, nodeId);
|
||||
if (!result.ok) {
|
||||
setStatus('无法删除该节点。');
|
||||
const deleteNode = useCallback(
|
||||
(nodeId: NodeId, treeId = activeImageId) => {
|
||||
if (!treeId) return;
|
||||
const tree = editorUiTrees.find(
|
||||
(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;
|
||||
}
|
||||
if (selectedNodeId === nodeId) setSelectedNodeId(null);
|
||||
return result;
|
||||
}
|
||||
},
|
||||
[activeImageId, editorDeleteNode, editorUiTrees, selectedNodeId],
|
||||
);
|
||||
|
||||
function selectSprite(id: SpriteAssetId) {
|
||||
setSelectedNodeId(null);
|
||||
@@ -939,6 +974,7 @@ export function useUiEditorSession(
|
||||
|
||||
async function suggestUiDesignSemantics() {
|
||||
if (isSuggesting || isWorkflowBusy) return;
|
||||
setCompletionNotice(null);
|
||||
setSuggestionStatus(null);
|
||||
setIsSuggesting(true);
|
||||
try {
|
||||
@@ -949,11 +985,19 @@ export function useUiEditorSession(
|
||||
);
|
||||
editor.replaceState(applyUiDesignSuggestions(snapshot, suggestions));
|
||||
setHasSuggested(true);
|
||||
setSuggestionStatus(`已应用 ${suggestions.length} 条参考图语义建议。`);
|
||||
reportWorkflowCompletion(
|
||||
'reference-analysis',
|
||||
'success',
|
||||
`参考图分析完成:已应用 ${suggestions.length} 条参考图语义建议`,
|
||||
setSuggestionStatus,
|
||||
);
|
||||
});
|
||||
} catch (cause) {
|
||||
setSuggestionStatus(
|
||||
reportWorkflowCompletion(
|
||||
'reference-analysis',
|
||||
'failure',
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
setSuggestionStatus,
|
||||
);
|
||||
} finally {
|
||||
setIsSuggesting(false);
|
||||
@@ -962,6 +1006,7 @@ export function useUiEditorSession(
|
||||
|
||||
async function recognizeUi() {
|
||||
if (isRecognizing || isWorkflowBusy) return;
|
||||
setCompletionNotice(null);
|
||||
setRecognitionStatus(null);
|
||||
setIsRecognizing(true);
|
||||
try {
|
||||
@@ -970,14 +1015,27 @@ export function useUiEditorSession(
|
||||
projectPath,
|
||||
state: snapshot,
|
||||
});
|
||||
editor.replaceState(applyRecognitionResult(snapshot, result));
|
||||
const nextState = applyRecognitionResult(snapshot, result);
|
||||
editor.replaceState(nextState);
|
||||
setHasRecognized(true);
|
||||
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) {
|
||||
setRecognitionStatus(
|
||||
reportWorkflowCompletion(
|
||||
'structure-recognition',
|
||||
'failure',
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
setRecognitionStatus,
|
||||
);
|
||||
} finally {
|
||||
setIsRecognizing(false);
|
||||
@@ -1005,6 +1063,7 @@ export function useUiEditorSession(
|
||||
|
||||
async function bindComponents() {
|
||||
if (isBinding || isWorkflowBusy) return;
|
||||
setCompletionNotice(null);
|
||||
setBindingStatus(null);
|
||||
setIsBinding(true);
|
||||
try {
|
||||
@@ -1032,13 +1091,21 @@ export function useUiEditorSession(
|
||||
history: index < batches.length - 1 ? 'skip' : 'record',
|
||||
});
|
||||
}
|
||||
setBindingStatus(
|
||||
`组件绑定完成(${batches.length}/${batches.length})。`,
|
||||
reportWorkflowCompletion(
|
||||
'visual-binding',
|
||||
'success',
|
||||
`视觉素材绑定完成:已处理 ${batches.length}/${batches.length} 个批次`,
|
||||
setBindingStatus,
|
||||
);
|
||||
setHasBound(true);
|
||||
});
|
||||
} catch (cause) {
|
||||
setBindingStatus(cause instanceof Error ? cause.message : String(cause));
|
||||
reportWorkflowCompletion(
|
||||
'visual-binding',
|
||||
'failure',
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
setBindingStatus,
|
||||
);
|
||||
} finally {
|
||||
setIsBinding(false);
|
||||
}
|
||||
@@ -1303,6 +1370,7 @@ export function useUiEditorSession(
|
||||
isBinding,
|
||||
hasBound,
|
||||
bindingStatus,
|
||||
completionNotice,
|
||||
bindComponents,
|
||||
requestStepChange,
|
||||
continueToNextStep: () => {
|
||||
@@ -1310,6 +1378,7 @@ export function useUiEditorSession(
|
||||
},
|
||||
confirmStepChange,
|
||||
cancelStepChange: () => setPendingWorkflowStepChange(null),
|
||||
dismissCompletionNotice: () => setCompletionNotice(null),
|
||||
},
|
||||
dialogs: {
|
||||
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,203 @@
|
||||
// @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);
|
||||
}
|
||||
|
||||
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',
|
||||
ctrlKey: true,
|
||||
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: '=',
|
||||
ctrlKey: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(renderedScale(rendered.container)).toBe(initial);
|
||||
|
||||
fireEvent.pointerEnter(preview);
|
||||
expect(
|
||||
fireEvent.keyDown(zoomButton, {
|
||||
key: '=',
|
||||
ctrlKey: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(renderedScale(rendered.container)).toBe(initial);
|
||||
|
||||
fireEvent.pointerLeave(preview);
|
||||
fireEvent.focus(preview);
|
||||
fireEvent.keyDown(window, {
|
||||
key: '-',
|
||||
code: 'Minus',
|
||||
ctrlKey: true,
|
||||
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',
|
||||
ctrlKey: true,
|
||||
cancelable: true,
|
||||
});
|
||||
expect(renderedScale(rendered.container)).toBe(1);
|
||||
|
||||
fireEvent.keyDown(window, {
|
||||
key: '0',
|
||||
ctrlKey: true,
|
||||
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: '=',
|
||||
ctrlKey: true,
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { result } = await renderLoadedSession(stateWithPages(['page']));
|
||||
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('绑定视觉素材');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user