Merge branch 'master' into fix/wallet-ledger-reason
Project CI / Backend tests (pull_request) Successful in 5m26s
Project CI / Repository checks (pull_request) Successful in 2m16s
Project CI / Frontend tests (pull_request) Successful in 2m51s
Project CI / Native shell tests (pull_request) Successful in 28m33s

This commit is contained in:
2026-09-08 14:34:13 +08:00
26 changed files with 1573 additions and 107 deletions
@@ -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>
);
}
@@ -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);
}
}
@@ -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 { 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('绑定视觉素材');
});
});
@@ -58,6 +58,10 @@ Runtime 确认卡与普通聊天确认卡必须共用“信息区 + 固定操作
## 2026-08-19 UI Editor 节点右键菜单
## 2026-09-05 UI Editor 节点树快捷删除
左侧 `UI Tree` 每个可删除节点行在右侧提供桌面端快捷删除按钮,眼睛与删除按钮组成固定宽度的右侧动作列;整棵树保留横向滚动,深层节点的缩进、完整名称和组件数随树内容一起滚动,不使用省略号截断。沿用右键菜单的页面根节点禁删规则。快捷删除与右键删除共用页面级 `requestDelete` 入口:叶子节点直接调用现有删除命令;包含后代节点时先打开模态确认,正文显示节点名称及将同时删除的后代节点数量,按钮为“取消 / 删除”。确认期间使用现有 `ThemedModal` 的模态行为,取消或完成后关闭弹窗。底层 `deleteNode` 命令继续保持无确认,以兼容键盘 `Delete` 及已有状态测试;锁定态下快捷按钮和右键删除均不可执行。
## 2026-08-20 UI Editor 最终预览互斥子节点
最终预览中,选中一个 `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` 重做。
- `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 判定、保存和后端持久化。历史快照不包含当前设计图、节点选择、隐藏集合或视口;恢复后若当前选择已不存在,页面清理无效选择并保持安全空态。
删除节点后,若当前选中节点是被删除节点或其任意后代,页面将选区清空为 `null`;删除成功不强制抢占焦点。该选择清理属于 UI 临时状态,不写入撤销历史。
## 验收标准
1. 单次字段编辑可撤销和重做。
@@ -67,3 +73,4 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被
8. 工具栏按钮、禁用态和桌面快捷键可用,文本控件保留原生撤销。
9. no-op、锁定、校验失败和不存在目标不进入历史。
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 MIN_SCALE = 0.025;
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 DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
export const FIT_VIEW_PADDING = 10;

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