Merge remote-tracking branch 'origin/codex/agc-client-mcp-facade' into codex/agc-client-mcp-facade
Project CI / Repository checks (pull_request) Failing after 1m9s
Project CI / Native shell tests (pull_request) Failing after 5m22s
Project CI / Frontend tests (pull_request) Successful in 3m20s
Project CI / Backend tests (pull_request) Successful in 6m21s

This commit is contained in:
2026-09-08 12:42:49 +08:00
36 changed files with 1932 additions and 146 deletions
@@ -266,6 +266,7 @@ fn game_creator_codex_app_server_connection_error(
) -> platform_llm::LlmError {
match game_creator_codex_app_server_error_http_status(info, field) {
Some(401 | 403) => game_creator_codex_app_server_error_kind("unauthorized"),
Some(413) => game_creator_codex_app_server_error_kind("request-too-large"),
Some(status_code) => platform_llm::LlmError::Upstream {
status_code,
message: "Codex app-server 连接上游失败".to_string(),
@@ -303,6 +304,30 @@ fn game_creator_codex_app_server_error_detail_indicates_auth_failure(
|| detail.contains("http 403")
}
fn game_creator_codex_app_server_error_detail_indicates_request_too_large(
error: &serde_json::Value,
) -> bool {
let Some(error) = error.as_object() else {
return false;
};
let detail = ["message", "additionalDetails", "code"]
.into_iter()
.filter_map(|field| error.get(field).and_then(serde_json::Value::as_str))
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase();
if detail.is_empty() {
return false;
}
detail.contains("413 payload too large")
|| detail.contains("http 413")
|| detail.contains("status 413")
|| detail.contains("payload_too_large")
|| detail.contains("payload too large")
|| detail.contains("request too large")
|| detail.contains("provider request too large")
}
fn game_creator_codex_app_server_error_detail_indicates_insufficient_mud_points(
error: &serde_json::Value,
) -> bool {
@@ -333,6 +358,9 @@ fn game_creator_codex_app_server_failed_turn_error(
message: "泥点余额不足".to_string(),
};
}
if game_creator_codex_app_server_error_detail_indicates_request_too_large(error) {
return game_creator_codex_app_server_error_kind("request-too-large");
}
if game_creator_codex_app_server_error_detail_indicates_auth_failure(error) {
return game_creator_codex_app_server_error_kind("unauthorized");
}
@@ -4787,6 +4815,12 @@ mod tests {
"codex-app-server-error:unauthorized".to_string(),
),
),
(
serde_json::json!({"httpConnectionFailed":{"httpStatusCode":413}}),
platform_llm::LlmError::InvalidRequest(
"codex-app-server-error:request-too-large".to_string(),
),
),
(
serde_json::json!({"httpConnectionFailed":{"httpStatusCode":429}}),
platform_llm::LlmError::Upstream {
@@ -4847,6 +4881,31 @@ mod tests {
}
}
#[test]
fn codex_app_server_failed_turn_maps_request_too_large_details() {
for detail in [
"HTTP 413 Payload Too Large",
"status 413",
"PAYLOAD_TOO_LARGE",
"provider request too large",
] {
let error = game_creator_codex_app_server_failed_turn_error(&serde_json::json!({
"status": "failed",
"error": {
"message": detail,
"additionalDetails": "private upstream diagnostics",
"codexErrorInfo": "other"
}
}));
assert_eq!(
error,
platform_llm::LlmError::InvalidRequest(
"codex-app-server-error:request-too-large".to_string(),
)
);
}
}
#[test]
fn codex_app_server_failed_turn_maps_insufficient_mud_points_to_stable_upstream_error() {
for detail in [
@@ -35,7 +35,9 @@ const SYSTEM_PROMPT: &str = r#"
* 对每个 Component,直接完整返回其全部参数.
* 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。
* 纯结构节点可以返回空数组并标为 NoProblem。
* 容器背景等推荐使用Simple + preserve_aspect: false 实现与node大小一致
* 面向用户的 reason 使用中文。
"#;
#[derive(Clone, Debug, Deserialize, JsonSchema)]
@@ -4,6 +4,7 @@ import {
ackClientErrorEventsWithRetry,
type ClientErrorEvent,
type DiagnosticLogFile,
getPendingClientErrorEvents,
getStableErrorReportSubmissionId,
readApplicationDiagnosticLogs,
submitErrorReportBatch,
@@ -12,10 +13,12 @@ import { ThemedModal } from '../modal/ThemedModal';
type ErrorReportDialogProps = {
open: boolean;
events: ClientErrorEvent[];
onClose: () => void;
fallbackEvents?: ClientErrorEvent[];
};
const emptyFallbackEvents: ClientErrorEvent[] = [];
function diagnosticLogLabel(
logsReady: boolean,
logsError: boolean,
@@ -28,9 +31,12 @@ function diagnosticLogLabel(
export function ErrorReportDialog({
open,
events,
onClose,
fallbackEvents = emptyFallbackEvents,
}: ErrorReportDialogProps) {
const [events, setEvents] = useState<ClientErrorEvent[]>([]);
const [eventsReady, setEventsReady] = useState(false);
const [eventsError, setEventsError] = useState(false);
const [selectedFingerprints, setSelectedFingerprints] = useState<Set<string>>(
new Set(),
);
@@ -45,29 +51,59 @@ export function ErrorReportDialog({
useEffect(() => {
if (!open) return;
setSelectedFingerprints(
new Set(events.slice(0, 1).map((event) => event.fingerprint)),
);
let disposed = false;
setEvents([]);
setEventsReady(false);
setEventsError(false);
setSelectedFingerprints(new Set());
setStatus('');
setLogsReady(false);
setLogsError(false);
void getPendingClientErrorEvents()
.then((nextEvents) => {
if (disposed) return;
setEvents(nextEvents);
setSelectedFingerprints(
new Set(nextEvents.map((event) => event.fingerprint)),
);
setEventsReady(true);
})
.catch(() => {
if (disposed) return;
if (fallbackEvents.length) {
setEvents(fallbackEvents);
setSelectedFingerprints(
new Set(fallbackEvents.map((event) => event.fingerprint)),
);
setEventsReady(true);
setStatus('最新错误读取失败,已使用打开通知时的快照');
return;
}
setEventsError(true);
setEventsReady(true);
});
void readApplicationDiagnosticLogs()
.then((nextLogs) => {
if (disposed) return;
setLogs(nextLogs);
setLogsReady(true);
})
.catch(() => {
if (disposed) return;
setLogs([]);
setLogsError(true);
setLogsReady(true);
});
}, [events, open]);
return () => {
disposed = true;
};
}, [fallbackEvents, open]);
const submit = useCallback(async () => {
const selectedEvents = events.filter((event) =>
selectedFingerprints.has(event.fingerprint),
);
if (!selectedEvents.length || busy || !logsReady) return;
if (!selectedEvents.length || busy || !eventsReady || !logsReady) return;
if (includeLogs && logsError) return;
setBusy(true);
setStatus('正在提交…');
@@ -92,6 +128,7 @@ export function ErrorReportDialog({
}, [
busy,
description,
eventsReady,
events,
includeLogs,
logs,
@@ -124,7 +161,11 @@ export function ErrorReportDialog({
<div className="my-5 grid gap-4">
<section>
<strong>{events.length}</strong>
{events.length ? (
{!eventsReady && <p></p>}
{eventsReady && eventsError && (
<p role="alert"></p>
)}
{eventsReady && !eventsError && events.length > 0 && (
<ul className="m-0 mt-2 grid list-none gap-2 p-0">
{events.map((event) => (
<li
@@ -154,7 +195,8 @@ export function ErrorReportDialog({
</li>
))}
</ul>
) : (
)}
{eventsReady && !eventsError && !events.length && (
<p></p>
)}
</section>
@@ -191,11 +233,12 @@ export function ErrorReportDialog({
</button>
<button
className="cursor-pointer rounded-[9px] border-0 bg-[var(--platform-button-primary-fill)] px-3.5 py-[9px] text-[var(--platform-button-primary-text)]"
className="cursor-pointer rounded-[9px] border border-(--platform-button-primary-border) bg-(image:--platform-button-primary-fill) px-3.5 py-[9px] text-(--platform-button-primary-text)"
type="button"
onClick={() => void submit()}
disabled={
busy ||
!eventsReady ||
!selectedFingerprints.size ||
!logsReady ||
(includeLogs && logsError)
@@ -12,7 +12,10 @@ export function ErrorReportNotice() {
const [notificationEvents, setNotificationEvents] = useState<
ClientErrorEvent[]
>([]);
const [reportEvents, setReportEvents] = useState<ClientErrorEvent[]>([]);
const [reportFallbackEvents, setReportFallbackEvents] = useState<
ClientErrorEvent[]
>([]);
const [reportOpen, setReportOpen] = useState(false);
const requestId = useRef(0);
const retryTimer = useRef<number | null>(null);
@@ -65,31 +68,37 @@ export function ErrorReportNotice() {
}, [loadEvents]);
const openReport = () => {
setReportEvents(notificationEvents);
setReportFallbackEvents(notificationEvents);
setReportOpen(true);
setNotificationEvents([]);
};
const closeReport = () => {
setReportOpen(false);
setReportFallbackEvents([]);
};
return (
<>
{notificationEvents.length ? (
<aside
className="fixed top-[calc(var(--window-chrome-height)+14px)] right-4 z-40 flex max-w-[min(420px,calc(100vw-2rem))] items-center gap-2.5 rounded-xl border border-[var(--platform-surface-border)] bg-[var(--platform-subpanel-fill)] px-3.5 py-3 text-[var(--platform-text-strong)] shadow-[0_12px_30px_rgb(36_20_12_/_18%)] max-[560px]:top-[calc(var(--window-chrome-height)+8px)] max-[560px]:right-2 max-[560px]:left-2 max-[560px]:max-w-none"
className="fixed top-[calc(var(--window-chrome-height)+12px)] right-4 z-40 flex max-w-[min(520px,calc(100vw-2rem))] items-center gap-4 rounded-xl border border-[#efc9ae] bg-[#fffaf5] px-3.5 py-3 shadow-[0_8px_24px_rgb(100_49_26_/_16%)]"
role="status"
aria-live="polite"
>
<span className="flex-[1_1_auto]">
{notificationEvents.length}
</span>
<div className="grid min-w-0 gap-[3px]">
<strong className="text-[13px] text-[#4a220f]"></strong>
</div>
<button
className="min-h-9 cursor-pointer rounded-lg border-0 bg-[var(--platform-button-primary-fill)] px-2.5 text-[var(--platform-button-primary-text)] last:w-9 last:bg-transparent last:px-0 last:text-xl last:text-[var(--platform-text-soft)]"
className="flex-[0_0_auto] cursor-pointer rounded-lg border-0 bg-[#c7653d] px-3 py-[7px] text-[12px] font-bold text-white disabled:cursor-wait disabled:opacity-[0.65]"
type="button"
onClick={openReport}
>
</button>
<button
className="min-h-9 cursor-pointer rounded-lg border-0 bg-[var(--platform-button-primary-fill)] px-2.5 text-[var(--platform-button-primary-text)] last:w-9 last:bg-transparent last:px-0 last:text-xl last:text-[var(--platform-text-soft)]"
type="button"
className="inline-flex h-7 w-7 flex-[0_0_auto] cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 text-[#8d6a58] hover:bg-[rgb(199_101_61_/_12%)] hover:text-[#4a220f] disabled:cursor-wait disabled:opacity-50"
onClick={() => {
const dismissed = notificationEvents;
setNotificationEvents([]);
@@ -102,9 +111,9 @@ export function ErrorReportNotice() {
</aside>
) : null}
<ErrorReportDialog
open={reportEvents.length > 0}
events={reportEvents}
onClose={() => setReportEvents([])}
open={reportOpen}
fallbackEvents={reportFallbackEvents}
onClose={closeReport}
/>
</>
);
@@ -1951,6 +1951,7 @@ export function projectRuntimeVisibleError(
'session-budget-exceeded': '本次会话预算已耗尽,请缩小任务范围或新建任务',
'usage-limit-exceeded': '智能创作用量已达上限,请检查账户额度后重试',
unauthorized: '智能服务鉴权失败,请重新登录后重试',
'request-too-large': '模型请求体过大,请减少参考图或上下文后重试',
'bad-request': '智能创作请求无效,请稍后重试',
'cyber-policy': '智能创作安全策略拒绝了本次请求,请调整任务内容',
'sandbox-error': '智能创作隔离环境启动失败,请重试或检查本机环境',
@@ -1972,6 +1973,7 @@ export function projectRuntimeVisibleError(
'session-budget-exceeded': '本次会话预算已耗尽,请缩小任务范围或新建任务',
'usage-limit-exceeded': '用量已达上限,请检查账户额度后重试',
unauthorized: '鉴权失败,请重新登录后重试',
'request-too-large': '模型请求体过大,请减少参考图或上下文后重试',
'bad-request': '请求无效,请稍后重试',
'cyber-policy': '安全策略拒绝了本次请求,请调整任务内容',
'sandbox-error': '工作区隔离启动失败,请检查项目目录后重试',
@@ -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,81 @@
/** @vitest-environment jsdom */
import { cleanup, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
const mockState = vi.hoisted(() => ({
getPending: vi.fn(),
readLogs: vi.fn(),
}));
vi.mock('../src/services/errorReporting', () => ({
ackClientErrorEventsWithRetry: vi.fn(),
getPendingClientErrorEvents: mockState.getPending,
getStableErrorReportSubmissionId: vi.fn(),
readApplicationDiagnosticLogs: mockState.readLogs,
submitErrorReportBatch: vi.fn(),
}));
vi.mock('../src/components/modal/ThemedModal', () => ({
ThemedModal: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <div role="dialog">{children}</div> : null,
}));
import { ErrorReportDialog } from '../src/components/error-report/ErrorReportDialog';
const event = {
eventId: 'rust-error-1',
fingerprint: 'fingerprint-1',
source: 'test',
message: '打开面板时读取的错误',
occurredAt: '1',
count: 1,
};
describe('ErrorReportDialog', () => {
afterEach(() => {
cleanup();
mockState.getPending.mockReset();
mockState.readLogs.mockReset();
});
it('只在面板打开时读取最新错误快照', async () => {
mockState.getPending.mockResolvedValue([event]);
mockState.readLogs.mockResolvedValue([]);
const view = render(<ErrorReportDialog open={false} onClose={vi.fn()} />);
expect(mockState.getPending).not.toHaveBeenCalled();
view.rerender(<ErrorReportDialog open onClose={vi.fn()} />);
expect(await screen.findByText(event.message)).toBeTruthy();
expect(mockState.getPending).toHaveBeenCalledTimes(1);
});
it('读取错误事件失败时显示不可用状态而不是空列表', async () => {
mockState.getPending.mockRejectedValue(new Error('读取失败'));
mockState.readLogs.mockResolvedValue([]);
render(<ErrorReportDialog open onClose={vi.fn()} />);
expect(
await screen.findByText('错误事件暂不可用,请关闭后重试。'),
).toBeTruthy();
expect(screen.queryByText('当前没有待报告的错误。')).toBeNull();
});
it('最新快照读取失败时使用打开通知时的事件快照', async () => {
mockState.getPending.mockRejectedValue(new Error('瞬时读取失败'));
mockState.readLogs.mockResolvedValue([]);
render(
<ErrorReportDialog open fallbackEvents={[event]} onClose={vi.fn()} />,
);
expect(await screen.findByText(event.message)).toBeTruthy();
expect(
screen.getByText('最新错误读取失败,已使用打开通知时的快照'),
).toBeTruthy();
});
});
@@ -1,6 +1,12 @@
/** @vitest-environment jsdom */
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
const mockState = vi.hoisted(() => ({
@@ -8,6 +14,10 @@ const mockState = vi.hoisted(() => ({
listeners: [] as Array<() => void>,
}));
const dialogState = vi.hoisted(() => ({
fallbackEvents: [] as (typeof event)[],
}));
vi.mock('../src/services/errorReporting', () => ({
getPendingClientErrorEvents: mockState.getPending,
subscribeClientErrorEvents: (listener: () => void) => {
@@ -21,7 +31,16 @@ vi.mock('../src/services/errorReporting', () => ({
}));
vi.mock('../src/components/error-report/ErrorReportDialog', () => ({
ErrorReportDialog: () => null,
ErrorReportDialog: ({
fallbackEvents,
open,
}: {
fallbackEvents?: (typeof event)[];
open: boolean;
}) => {
dialogState.fallbackEvents = fallbackEvents ?? [];
return open ? <div data-testid="error-report-dialog" /> : null;
},
}));
import { ErrorReportNotice } from '../src/components/error-report/ErrorReportNotice';
@@ -47,7 +66,7 @@ describe('ErrorReportNotice', () => {
render(<ErrorReportNotice />);
expect(await screen.findByText('发现 1 个问题')).toBeTruthy();
expect(await screen.findByText('发现问题')).toBeTruthy();
expect(mockState.getPending).toHaveBeenCalledTimes(1);
});
@@ -61,7 +80,19 @@ describe('ErrorReportNotice', () => {
mockState.listeners[0]?.();
expect(await screen.findByText('发现 1 个问题')).toBeTruthy();
expect(await screen.findByText('发现问题')).toBeTruthy();
expect(mockState.getPending).toHaveBeenCalledTimes(2);
});
it('打开报告时保留通知快照作为对话框 fallback', async () => {
mockState.getPending.mockResolvedValue([event]);
render(<ErrorReportNotice />);
await screen.findByText('发现问题');
fireEvent.click(screen.getByRole('button', { name: '查看并报告' }));
expect(await screen.findByTestId('error-report-dialog')).toBeTruthy();
expect(dialogState.fallbackEvents).toEqual([event]);
});
});
@@ -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');
});
});
@@ -695,6 +695,13 @@ describe('Agent Runtime Provider 状态投影', () => {
true,
),
).toBe('陶泥儿智能创作 用量已达上限,请检查账户额度后重试');
expect(
projectRuntimeVisibleError(
'codex-app-server-error:request-too-large',
'陶泥儿智能创作',
true,
),
).toBe('陶泥儿智能创作 模型请求体过大,请减少参考图或上下文后重试');
expect(
projectRuntimeVisibleError(
'codex-app-server-terminal-unknown: 等待 turn/completed 超时',
@@ -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';

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