修复 UI 编辑器从属关系与保存交互
状态层和持久化层拒绝 slave_to 循环,Inspector 过滤后代候选 统一节点拖拽缩放手势状态机并校验几何值 保存失败改为页面级安全提示并支持稳定重试 补充回归测试及 UI Editor 决策文档
This commit is contained in:
@@ -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);
|
||||
|
||||
+181
-88
@@ -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`,前置检查负责阻止跳过绑定阶段。
|
||||
|
||||
Reference in New Issue
Block a user