修复 UI 编辑器从属关系与保存交互

状态层和持久化层拒绝 slave_to 循环,Inspector 过滤后代候选

统一节点拖拽缩放手势状态机并校验几何值

保存失败改为页面级安全提示并支持稳定重试

补充回归测试及 UI Editor 决策文档
This commit is contained in:
2026-08-18 18:49:36 +08:00
parent c39049b7ba
commit 1e2537e036
10 changed files with 578 additions and 95 deletions
@@ -465,6 +465,7 @@ fn validate_state(state: &State) -> Result<(), String> {
return Err("界面图 slaveTo 引用了不存在的界面图".to_string());
}
}
validate_slave_to_acyclic(state)?;
for (id, sprite) in &state.sprite_assets {
validate_id(id.as_str(), "独立素材 ID")?;
if sprite.asset_id != *id {
@@ -490,6 +491,23 @@ fn validate_state(state: &State) -> Result<(), String> {
Ok(())
}
fn validate_slave_to_acyclic(state: &State) -> Result<(), String> {
for start in state.ui_design_images.keys() {
let mut current = Some(start);
let mut visited = HashSet::new();
while let Some(id) = current {
if !visited.insert(id) {
return Err("界面图 slaveTo 不能形成循环".to_string());
}
current = state
.ui_design_images
.get(id)
.and_then(|image| image.metadata.slave_to.as_ref());
}
}
Ok(())
}
fn validate_node(
node: &Node,
state: &State,
@@ -951,6 +969,45 @@ mod tests {
);
}
#[test]
fn rejects_cyclic_slave_to_relationships() {
let state: State = serde_json::from_value(serde_json::json!({
"ui_trees": [],
"ui_design_images": {
"page": {
"metadata": {
"name": "主界面",
"description": "",
"role": "Page",
"slave_to": "section"
},
"path": "page.png",
"pixel_size": [1280.0, 720.0],
"pixels_per_unit": 1.0
},
"section": {
"metadata": {
"name": "子页面",
"description": "",
"role": "Section",
"slave_to": "page"
},
"path": "section.png",
"pixel_size": [1280.0, 720.0],
"pixels_per_unit": 1.0
}
},
"sprite_assets": {},
"font_assets": {}
}))
.expect("deserialize cyclic state");
assert_eq!(
validate_state(&state),
Err("界面图 slaveTo 不能形成循环".to_string())
);
}
#[test]
fn recovers_last_valid_state_when_primary_is_corrupt() {
let (directory, asset_id) = fixture();
@@ -69,6 +69,23 @@ type NodeLocation = {
index: number;
};
function wouldCreateSlaveToCycle(
images: State['ui_design_images'],
id: UIDesignImageId,
slaveTo: UIDesignImageId,
): boolean {
let current: UIDesignImageId | null = slaveTo;
const visited = new Set<UIDesignImageId>();
while (current !== null) {
if (current === id || visited.has(current)) return true;
visited.add(current);
const image: UIDesignImage | undefined = images[current];
if (!image) return true;
current = image.metadata.slave_to;
}
return false;
}
function visitNodes(node: Node, visit: (node: Node) => void): void {
visit(node);
for (const child of node.children) visitNodes(child, visit);
@@ -657,6 +674,12 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
if (slaveTo !== null && !(slaveTo in current.ui_design_images)) {
return { ok: false, reason: 'missing' };
}
if (
slaveTo !== null &&
wouldCreateSlaveToCycle(current.ui_design_images, id, slaveTo)
) {
return { ok: false, reason: 'invalid:slave_to 不能形成循环' };
}
const next = cloneState(current);
next.ui_design_images[id]!.metadata.slave_to = slaveTo;
commit(next);
@@ -2,6 +2,7 @@ import {
type PointerEvent as ReactPointerEvent,
type RefObject,
useCallback,
useEffect,
useRef,
} from 'react';
@@ -22,44 +23,61 @@ import {
type ViewportScale = { scale: number };
type NodeDragState = {
type GestureBase = {
treeId: string;
nodeId: string;
pointerId: number;
target: HTMLDivElement;
startClientX: number;
startClientY: number;
startTransform: UiNode['layout']['transform'];
hasMoved: boolean;
};
type NodeResizeState = {
nodeId: string;
pointerId: number;
handle: ResizeHandle;
startClientX: number;
startClientY: number;
startTransform: UiNode['layout']['transform'];
startRect: PageRect;
parentRect: PageRect;
ratioAxis: ResizeAxis | null;
hasMoved: boolean;
};
type ActiveGesture =
| (GestureBase & { kind: 'drag' })
| (GestureBase & {
kind: 'resize';
handle: ResizeHandle;
startRect: PageRect;
parentRect: PageRect;
ratioAxis: ResizeAxis | null;
});
const DRAG_THRESHOLD_SCREEN_PX = 2;
function passedDragThreshold(
startClientX: number,
startClientY: number,
gesture: GestureBase,
event: ReactPointerEvent<HTMLDivElement>,
) {
return (
Math.hypot(event.clientX - startClientX, event.clientY - startClientY) >=
DRAG_THRESHOLD_SCREEN_PX
Math.hypot(
event.clientX - gesture.startClientX,
event.clientY - gesture.startClientY,
) >= DRAG_THRESHOLD_SCREEN_PX
);
}
function releasePointer(event: ReactPointerEvent<HTMLDivElement>) {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
function isFinitePoint(point: readonly number[]) {
return point.length === 2 && point.every(Number.isFinite);
}
function isFiniteRect(rect: PageRect) {
return isFinitePoint(rect.min) && isFinitePoint(rect.max);
}
function isFiniteTransform(transform: UiNode['layout']['transform']) {
return (
isFinitePoint(transform.anchor_min) &&
isFinitePoint(transform.anchor_max) &&
isFinitePoint(transform.offset_min) &&
isFinitePoint(transform.offset_max)
);
}
function releasePointer(target: HTMLDivElement, pointerId: number) {
if (target.hasPointerCapture(pointerId)) {
target.releasePointerCapture(pointerId);
}
}
@@ -78,60 +96,114 @@ export function useNodeTransformInteraction({
tree: UITree | null;
viewportRef: RefObject<ViewportScale>;
}) {
const nodeDragRef = useRef<NodeDragState | null>(null);
const nodeResizeRef = useRef<NodeResizeState | null>(null);
const activeGestureRef = useRef<ActiveGesture | null>(null);
const cancelGesture = useCallback(() => {
const gesture = activeGestureRef.current;
if (gesture) releasePointer(gesture.target, gesture.pointerId);
activeGestureRef.current = null;
}, []);
useEffect(() => cancelGesture, [cancelGesture]);
useEffect(() => {
const gesture = activeGestureRef.current;
if (
gesture &&
(gesture.treeId !== activeImageId ||
tree?.src_ui_design !== gesture.treeId)
) {
cancelGesture();
}
}, [activeImageId, cancelGesture, tree]);
const acceptsGestureEvent = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const gesture = activeGestureRef.current;
return (
gesture !== null &&
gesture.pointerId === event.pointerId &&
gesture.treeId === activeImageId
);
},
[activeImageId],
);
const onNodePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>, node: UiNode) => {
if (event.button !== 0 || spaceHeld || node.id === tree?.root.id) return;
if (
activeGestureRef.current !== null ||
event.button !== 0 ||
spaceHeld ||
!activeImageId ||
node.id === tree?.root.id ||
!isFiniteTransform(node.layout.transform)
) {
return;
}
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
controller.selectNode(node.id);
nodeDragRef.current = {
activeGestureRef.current = {
kind: 'drag',
treeId: activeImageId,
nodeId: node.id,
pointerId: event.pointerId,
target: event.currentTarget,
startClientX: event.clientX,
startClientY: event.clientY,
startTransform: structuredClone(node.layout.transform),
hasMoved: false,
};
},
[controller, spaceHeld, tree?.root.id],
[activeImageId, controller, spaceHeld, tree?.root.id],
);
const onNodePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation();
const drag = nodeDragRef.current;
if (!drag || drag.pointerId !== event.pointerId || !activeImageId) return;
if (
!drag.hasMoved &&
!passedDragThreshold(drag.startClientX, drag.startClientY, event)
) {
const gesture = activeGestureRef.current;
if (!gesture || gesture.kind !== 'drag' || !acceptsGestureEvent(event)) {
return;
}
drag.hasMoved = true;
const nextTransform = structuredClone(drag.startTransform);
const scale = viewportRef.current?.scale ?? 1;
const logicalDeltaX = (event.clientX - drag.startClientX) / scale;
const logicalDeltaY = (event.clientY - drag.startClientY) / scale;
event.stopPropagation();
if (!gesture.hasMoved && !passedDragThreshold(gesture, event)) return;
const scale = viewportRef.current?.scale;
if (!Number.isFinite(scale) || scale <= 0) {
cancelGesture();
return;
}
const logicalDeltaX = (event.clientX - gesture.startClientX) / scale;
const logicalDeltaY = (event.clientY - gesture.startClientY) / scale;
if (!Number.isFinite(logicalDeltaX) || !Number.isFinite(logicalDeltaY)) {
cancelGesture();
return;
}
gesture.hasMoved = true;
const nextTransform = structuredClone(gesture.startTransform);
nextTransform.offset_min[0] += logicalDeltaX;
nextTransform.offset_min[1] += logicalDeltaY;
nextTransform.offset_max[0] += logicalDeltaX;
nextTransform.offset_max[1] += logicalDeltaY;
controller.updateNodeTransform(activeImageId, drag.nodeId, nextTransform);
if (!isFiniteTransform(nextTransform)) {
cancelGesture();
return;
}
controller.updateNodeTransform(
gesture.treeId,
gesture.nodeId,
nextTransform,
);
},
[activeImageId, controller, viewportRef],
[acceptsGestureEvent, cancelGesture, controller, viewportRef],
);
const onNodePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!acceptsGestureEvent(event)) return;
event.stopPropagation();
if (nodeDragRef.current?.pointerId !== event.pointerId) return;
nodeDragRef.current = null;
releasePointer(event);
cancelGesture();
},
[],
[acceptsGestureEvent, cancelGesture],
);
const onNodeResizePointerDown = useCallback(
@@ -141,11 +213,18 @@ export function useNodeTransformInteraction({
handle: ResizeHandle,
) => {
if (
activeGestureRef.current !== null ||
event.button !== 0 ||
spaceHeld ||
!activeImageId ||
node.id === tree?.root.id ||
!tree ||
!logicalSize
!logicalSize ||
!Number.isFinite(logicalSize.width) ||
!Number.isFinite(logicalSize.height) ||
logicalSize.width <= 0 ||
logicalSize.height <= 0 ||
!isFiniteTransform(node.layout.transform)
) {
return;
}
@@ -154,14 +233,23 @@ export function useNodeTransformInteraction({
max: [logicalSize.width, logicalSize.height],
};
const context = findNodePageContext(tree.root, node.id, pageRect);
if (!context) return;
if (
!context ||
!isFiniteRect(context.rect) ||
!isFiniteRect(context.parentRect)
) {
return;
}
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
controller.selectNode(node.id);
nodeResizeRef.current = {
activeGestureRef.current = {
kind: 'resize',
treeId: activeImageId,
nodeId: node.id,
pointerId: event.pointerId,
target: event.currentTarget,
handle,
startClientX: event.clientX,
startClientY: event.clientY,
@@ -172,65 +260,70 @@ export function useNodeTransformInteraction({
hasMoved: false,
};
},
[controller, logicalSize, spaceHeld, tree],
[activeImageId, controller, logicalSize, spaceHeld, tree],
);
const onNodeResizePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation();
const resize = nodeResizeRef.current;
if (!resize || resize.pointerId !== event.pointerId || !activeImageId) {
return;
}
const gesture = activeGestureRef.current;
if (
!resize.hasMoved &&
!passedDragThreshold(resize.startClientX, resize.startClientY, event)
!gesture ||
gesture.kind !== 'resize' ||
!acceptsGestureEvent(event)
) {
return;
}
resize.hasMoved = true;
const scale = viewportRef.current?.scale ?? 1;
event.stopPropagation();
if (!gesture.hasMoved && !passedDragThreshold(gesture, event)) return;
const scale = viewportRef.current?.scale;
if (!Number.isFinite(scale) || scale <= 0) {
cancelGesture();
return;
}
const delta: [number, number] = [
(event.clientX - resize.startClientX) / scale,
(event.clientY - resize.startClientY) / scale,
(event.clientX - gesture.startClientX) / scale,
(event.clientY - gesture.startClientY) / scale,
];
if (event.shiftKey && !resize.ratioAxis) {
resize.ratioAxis = resolveProportionalResizeAxis(
[
resize.startRect.max[0] - resize.startRect.min[0],
resize.startRect.max[1] - resize.startRect.min[1],
],
delta,
);
if (!isFinitePoint(delta)) {
cancelGesture();
return;
}
const nextRect = resizePageRect(
resize.startRect,
resize.handle,
gesture.startRect,
gesture.handle,
delta,
event.shiftKey,
resize.ratioAxis ?? undefined,
event.shiftKey
? (gesture.ratioAxis ??= resolveProportionalResizeAxis(
[
gesture.startRect.max[0] - gesture.startRect.min[0],
gesture.startRect.max[1] - gesture.startRect.min[1],
],
delta,
))
: undefined,
);
if (!isFiniteRect(nextRect)) {
cancelGesture();
return;
}
const nextTransform = setOffsetsForPageRect(
gesture.startTransform,
nextRect,
gesture.parentRect,
);
if (!isFiniteTransform(nextTransform)) {
cancelGesture();
return;
}
gesture.hasMoved = true;
controller.updateNodeTransform(
activeImageId,
resize.nodeId,
setOffsetsForPageRect(
resize.startTransform,
nextRect,
resize.parentRect,
),
gesture.treeId,
gesture.nodeId,
nextTransform,
);
},
[activeImageId, controller, viewportRef],
);
const onNodeResizePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation();
if (nodeResizeRef.current?.pointerId !== event.pointerId) return;
nodeResizeRef.current = null;
releasePointer(event);
},
[],
[acceptsGestureEvent, cancelGesture, controller, viewportRef],
);
return {
@@ -239,6 +332,6 @@ export function useNodeTransformInteraction({
onNodePointerUp,
onNodeResizePointerDown,
onNodeResizePointerMove,
onNodeResizePointerUp,
onNodeResizePointerUp: onNodePointerUp,
};
}
@@ -68,6 +68,10 @@ export default function UiEditorPage({
savedStateSignature !== null &&
savedStateSignature !== stateSignature;
function saveFailureMessage() {
return '保存失败,请稍后重试。';
}
async function save(afterReturn = false) {
if (
!resourceId ||
@@ -101,8 +105,8 @@ export default function UiEditorPage({
setReturnConfirmOpen(false);
onBack?.();
}
} catch (error) {
setSaveError(error instanceof Error ? error.message : String(error));
} catch {
setSaveError(saveFailureMessage());
} finally {
setIsSaving(false);
}
@@ -112,7 +116,6 @@ export default function UiEditorPage({
if (!resourceId || isSaving || controller.isAiRunning) return;
setSaveAfterReturn(afterReturn);
if (controller.postCheckIssuesForSave().length > 0) {
if (afterReturn) setReturnConfirmOpen(false);
setSaveWarningOpen(true);
return;
}
@@ -156,6 +159,22 @@ export default function UiEditorPage({
</button>
</header>
) : null}
{saveError ? (
<div
className="mx-4 mt-3 flex shrink-0 items-center justify-between gap-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800"
role="alert"
>
<span>{saveError}</span>
<button
type="button"
className="shrink-0 rounded px-1 py-0.5 text-red-700 hover:bg-red-100"
aria-label="关闭保存错误提示"
onClick={() => setSaveError(null)}
>
</button>
</div>
) : null}
<ToolNavigation
activeStep={controller.activeStep}
furthestStepIndex={controller.furthestStepIndex}
@@ -179,9 +198,7 @@ export default function UiEditorPage({
>
<h2 className="m-0 text-base font-semibold"></h2>
{saveError ? (
<p className="mt-3 text-sm text-red-600" role="alert">
{saveError}
</p>
<p className="mt-3 text-sm text-red-600">{saveError}</p>
) : null}
<div className="mt-5 flex flex-wrap justify-end gap-2">
<button
@@ -22,6 +22,8 @@ import type { NodeId } from '../../features/ui-editor/types/NodeId';
import type { RecognitionDTO } from '../../features/ui-editor/types/RecognitionDTO';
import type { SpriteAssetId } from '../../features/ui-editor/types/SpriteAssetId';
import type { SpriteBorder } from '../../features/ui-editor/types/SpriteBorder';
import type { State } from '../../features/ui-editor/types/State';
import type { UIDesignImage } from '../../features/ui-editor/types/UIDesignImage';
import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId';
import type { UIDesignImageRole } from '../../features/ui-editor/types/UIDesignImageRole';
import type { UIDesignSuggestionTreeNode } from '../../features/ui-editor/types/UIDesignSuggestionTreeNode';
@@ -105,6 +107,25 @@ function isUiNodeEffectivelyVisible(
return null;
}
function isSlaveToDescendant(
images: State['ui_design_images'],
candidateId: UIDesignImageId,
ancestorId: UIDesignImageId | null,
): boolean {
if (ancestorId === null || candidateId === ancestorId) return true;
let current: UIDesignImageId | null = candidateId;
const visited = new Set<UIDesignImageId>();
while (current !== null) {
if (visited.has(current)) return true;
visited.add(current);
const image: UIDesignImage | undefined = images[current];
if (!image) return true;
current = image.metadata.slave_to;
if (current === ancestorId) return true;
}
return false;
}
export type PendingWorkflowStepChange = {
from: UiEditorStepId;
to: UiEditorStepId;
@@ -268,7 +289,9 @@ export function useUiEditorPage(
const selectedSprite = selectedSpriteId ? sprites[selectedSpriteId] : null;
const selectedFont = selectedFontId ? fonts[selectedFontId] : null;
const pageOptions = Object.entries(images).filter(
([, image]) => image.metadata.role === 'Page',
([id, image]) =>
image.metadata.role === 'Page' &&
!isSlaveToDescendant(images, id as UIDesignImageId, activeImageId),
);
const isAiRunning = isSuggesting || isRecognizing || isBinding || isMerging;
const nextStep: UiEditorStepId | null =
@@ -25,9 +25,23 @@ vi.mock('../src/components/modal/ThemedModal', () => ({
import { invoke } from '@tauri-apps/api/core';
import type {
UiDesignStateSnapshot,
UiDesignStateStore,
} from '../src/features/ui-editor/uiDesignStateStore';
import UiEditorPage from '../src/view/ui-editor';
import { useUiEditorPage } from '../src/view/ui-editor/useUiEditorPage';
const EMPTY_SNAPSHOT: UiDesignStateSnapshot = {
revision: 0,
state: {
ui_trees: [],
ui_design_images: {},
sprite_assets: {},
font_assets: {},
},
};
describe('UiEditorPage', () => {
it('uses the durable Tauri store for a project UI resource', async () => {
vi.mocked(invoke)
@@ -378,4 +392,31 @@ describe('UiEditorPage', () => {
expect(result.current.isNodePreviewVisible(childBId!)).toBe(true);
expect(result.current.isNodePreviewVisible(childBDescendantId!)).toBe(true);
});
it('shows a page-level save failure and allows a retry', async () => {
const stateStore: UiDesignStateStore = {
load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)),
save: vi.fn().mockRejectedValue(new Error('临时存储不可用')),
};
render(
createElement(UiEditorPage, {
projectPath: '/tmp/ui-editor',
resourceId: 'ui-resource',
stateStore,
}),
);
fireEvent.click(await screen.findByRole('button', { name: '保存' }));
fireEvent.click(await screen.findByRole('button', { name: '仍然保存' }));
expect((await screen.findByRole('alert')).textContent).toContain(
'保存失败,请稍后重试。',
);
expect(screen.getByRole('button', { name: '保存' })).toBeTruthy();
expect(stateStore.save).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '保存' }));
fireEvent.click(await screen.findByRole('button', { name: '仍然保存' }));
expect(stateStore.save).toHaveBeenCalledTimes(2);
});
});
@@ -284,6 +284,33 @@ describe('useUiEditorState', () => {
});
});
it('rejects self-references and slave_to cycles', () => {
const { result } = renderHook(() => useUiEditorState());
act(() => {
result.current.addDesignImages([
{ id: 'page', image: image('Page') },
{ id: 'section', image: image('Section') },
]);
result.current.setImageRole('page', 'Page');
result.current.setImageSlaveTo('section', 'page');
});
act(() => {
expect(result.current.setImageSlaveTo('page', 'page')).toEqual({
ok: false,
reason: 'invalid:slave_to 不能形成循环',
});
expect(result.current.setImageSlaveTo('page', 'section')).toEqual({
ok: false,
reason: 'invalid:slave_to 不能形成循环',
});
});
expect(result.current.state.ui_design_images.page?.metadata.slave_to).toBe(
null,
);
});
it('adds a batch atomically and rejects duplicates and limits', () => {
const { result } = renderHook(() => useUiEditorState());
@@ -0,0 +1,187 @@
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { Node as UiNode } from '../src/features/ui-editor/types/Node';
import type { UITree } from '../src/features/ui-editor/types/UITree';
import { useNodeTransformInteraction } from '../src/view/ui-editor/components/preview/useNodeTransformInteraction';
import type { UiEditorPageController } from '../src/view/ui-editor/useUiEditorPage';
function node(id: string, children: UiNode[] = []): UiNode {
return {
id,
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: id,
description: '',
layout_status: 'Passed',
components_status: 'Passed',
allow_llm_edit_layout: true,
allow_llm_edit_component: true,
source: 'System',
},
components: [],
children,
};
}
const child = node('child');
const pageTree: UITree = {
src_ui_design: 'page',
root: node('root', [child]),
};
function gestureTarget() {
const target = document.createElement('div');
Object.assign(target, {
setPointerCapture: vi.fn(),
hasPointerCapture: vi.fn(() => true),
releasePointerCapture: vi.fn(),
});
return target;
}
function pointerEvent(
target: HTMLDivElement,
pointerId: number,
clientX: number,
clientY: number,
) {
return {
button: 0,
clientX,
clientY,
currentTarget: target,
pointerId,
preventDefault: vi.fn(),
shiftKey: false,
stopPropagation: vi.fn(),
} as never;
}
function controller() {
return {
selectNode: vi.fn(),
updateNodeTransform: vi.fn(),
} as unknown as UiEditorPageController;
}
function renderInteraction({
activeImageId = 'page',
scale = 1,
tree = pageTree,
}: {
activeImageId?: string | null;
scale?: number;
tree?: UITree | null;
} = {}) {
const pageController = controller();
const viewportRef = { current: { scale } };
const hook = renderHook(
({ imageId, currentTree }) =>
useNodeTransformInteraction({
activeImageId: imageId,
controller: pageController,
logicalSize: { width: 320, height: 180 },
spaceHeld: false,
tree: currentTree,
viewportRef,
}),
{ initialProps: { imageId: activeImageId, currentTree: tree } },
);
return { ...hook, pageController, viewportRef };
}
describe('useNodeTransformInteraction', () => {
it('makes drag and resize mutually exclusive, then permits the next gesture', () => {
const { result, pageController } = renderInteraction();
const dragTarget = gestureTarget();
const resizeTarget = gestureTarget();
act(() => {
result.current.onNodePointerDown(
pointerEvent(dragTarget, 1, 0, 0),
child,
);
result.current.onNodeResizePointerDown(
pointerEvent(resizeTarget, 2, 0, 0),
child,
'se',
);
result.current.onNodePointerMove(pointerEvent(dragTarget, 1, 12, 8));
});
expect(resizeTarget.setPointerCapture).not.toHaveBeenCalled();
expect(pageController.updateNodeTransform).toHaveBeenCalledTimes(1);
act(() => {
result.current.onNodePointerUp(pointerEvent(dragTarget, 1, 12, 8));
result.current.onNodeResizePointerDown(
pointerEvent(resizeTarget, 2, 0, 0),
child,
'se',
);
});
expect(resizeTarget.setPointerCapture).toHaveBeenCalledWith(2);
});
it('cancels a gesture when its tree changes and ignores its later events', () => {
const { result, rerender, pageController } = renderInteraction();
const target = gestureTarget();
act(() => {
result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child);
});
act(() => {
rerender({
imageId: 'other',
currentTree: { ...pageTree, src_ui_design: 'other' },
});
});
act(() => {
result.current.onNodePointerMove(pointerEvent(target, 1, 12, 8));
});
expect(target.releasePointerCapture).toHaveBeenCalledWith(1);
expect(pageController.updateNodeTransform).not.toHaveBeenCalled();
});
it('cancels instead of persisting a non-finite transform', () => {
const { result, pageController, viewportRef } = renderInteraction();
const target = gestureTarget();
act(() => {
result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child);
viewportRef.current.scale = Number.NaN;
result.current.onNodePointerMove(pointerEvent(target, 1, 12, 8));
});
expect(target.releasePointerCapture).toHaveBeenCalledWith(1);
expect(pageController.updateNodeTransform).not.toHaveBeenCalled();
});
it('releases a captured pointer when the preview unmounts', () => {
const { result, unmount } = renderInteraction();
const target = gestureTarget();
act(() => {
result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child);
unmount();
});
expect(target.releasePointerCapture).toHaveBeenCalledWith(1);
});
});
@@ -7167,6 +7167,13 @@
- 三栏决策:Input / Preview / Inspector 永远同时存在,使用 Tailwind `minmax(0, 24fr) / minmax(0, 44fr) / minmax(0, 32fr)` 流式压缩;不得加入 JavaScript 窗口尺寸检测、设备分支或横向滚动。窄窗口下由 `min-w-0`、换行、纵向堆叠和各面板纵向滚动保持可操作性。
- 内部约束:编辑器内表单、Transform 网格、操作区和预览标题必须允许收缩与换行;禁止重新引入 `min-w-5xl`、固定三栏像素下限或会撑开工作区的面板最小宽度。
## 2026-08-18 UI Editor 从属页面、手势与保存失败边界
- 决策:`slave_to` 必须保持有向无环;前端状态层是交互时的最终不变量,Inspector 只作当前图及其后代的候选过滤,Rust UI State 持久化层再次拒绝环,防止绕过前端的坏状态落盘。
- 交互:节点拖拽和缩放使用单一内部手势状态机,任一时刻只允许一个指针手势。界面/树切换、pointer cancel 与卸载统一释放指针捕获;有限几何和正缩放是 State 更新的前置条件。
- 保存:冲突和异常显示在编辑器页面级错误条;未知异常只显示固定安全摘要。保存并返回仅在成功后离开,失败后保留返回确认,允许稳定重试。
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-08-18 UI Editor 从属页面、手势与保存失败边界”。
## 2026-08-18 UI Editor 左侧节点树跨界面移动
- 决策:左侧 UI 节点树使用 UI-only 的虚拟超级节点统一承载现有 `ui_trees` 页面根节点;超级节点和页面根节点不可拖动,普通节点可以在不同页面根节点之间移动,整个子树随节点移动并保留原 `transform`。跨界面“保留”只指复制原局部布局参数,不代表源、目标画布之间存在可比较的页面位置;不同界面图的画布尺寸、像素密度和父节点坐标空间独立,跨界面坐标换算不在本次范围内。
@@ -16,6 +16,14 @@ UI Editor Inspector 的全局只读状态唯一来源是 `controller.editor.isLo
全局锁定时所有会修改 State 的 Inspector 控件必须禁用或设为只读,包括文本 / 数值输入、选择器、复选框、组件增删改排和 Sprite 九宫格预设、数值、指针导轨编辑。查看、复制以及不产生 State 修改的展开 / 折叠保持可用。所有 mutation 回调还须在入口检查只读状态,不能只依赖禁用样式,避免键盘或程序化事件绕过锁定。Transform 内不再重复显示全局“只读 / 可编辑”徽章。
## 2026-08-18 UI Editor 从属页面、手势与保存失败边界
`UIDesignImage.metadata.slave_to` 是有向无环关系。编辑器状态层拒绝自指、会形成环的赋值以及已损坏或缺失的引用链;Inspector 只将当前图片及其下游后代从可选主页面中隐藏,不能替代状态层校验。UI State 写入前 Rust 持久化校验再次拒绝任意环,保证手工编辑或旧文件同样不能落盘。
预览节点拖拽与缩放共用单一内部手势状态机,每次只能有一个指针操作。状态机保存手势所属界面图、节点、指针和捕获目标;界面图或树切换、指针取消和组件卸载都会释放捕获并清空状态。所有输入和输出几何值必须为有限数,缩放比例还必须大于零;检查失败直接取消操作,不向 State 写入变换。
资源保存的冲突和异常均在编辑器页面持久显示,并可主动关闭;普通异常只显示固定安全摘要,不能显示底层错误正文。保存并返回只在保存成功后关闭返回确认并离开页面;失败后保留确认上下文,以便用户修复、重新加载或重试。
## 2026-08-18 UI Editor 结构识别、合并与增量导入边界
UI Editor 当前把“识别界面结构”定义为结构草稿阶段,而不是完整视觉还原阶段。识别 DTO 只负责输出节点层级、几何、名称、描述和置信度;节点组件暂为空,由后续“绑定视觉素材”阶段补齐 Image / Text 组件。`applyRecognitionResult` 可以整体替换当前 `ui_trees`,但该替换只代表结构结果,不能宣称已经保留截图中的视觉内容;组件状态继续为 `Pending`,前置检查负责阻止跳过绑定阶段。