UI编辑器撤销回退功能 #261

Merged
kdletters merged 29 commits from feat/ui-editor-edit-history into master 2026-09-03 19:31:26 +08:00
12 changed files with 797 additions and 40 deletions
@@ -33,6 +33,8 @@ export const EMPTY_UI_EDITOR_STATE: State = {
font_assets: {},
};
const MAX_HISTORY_LENGTH = 100;
export type UiEditorOperationFailureReason =
| 'locked'
| 'duplicate'
@@ -45,6 +47,15 @@ export type UiEditorOperationResult<T = undefined> =
| { ok: true; value: T }
| { ok: false; reason: UiEditorOperationFailureReason };
export type UiEditorHistoryState = {
canUndo: boolean;
canRedo: boolean;
};
export type UiEditorReplaceStateOptions = {
history?: 'record' | 'reset' | 'skip';
};
type UiEditorOperationFailure = Extract<UiEditorOperationResult, { ok: false }>;
export type NodeMetadataPatch = Partial<
@@ -227,8 +238,47 @@ function cloneState(state: State): State {
return structuredClone(state);
}
function sameResource<T>(left: T, right: T): boolean {
return JSON.stringify(left) === JSON.stringify(right);
function sameResource<T>(
left: T,
right: T,
seenPairs = new WeakMap<object, WeakSet<object>>(),
): boolean {
if (Object.is(left, right)) return true;
if (
typeof left !== 'object' ||
left === null ||
typeof right !== 'object' ||
right === null
) {
return false;
}
const leftObject = left as object;
const rightObject = right as object;
let seenRightObjects = seenPairs.get(leftObject);
if (seenRightObjects?.has(rightObject)) return true;
if (!seenRightObjects) {
seenRightObjects = new WeakSet<object>();
seenPairs.set(leftObject, seenRightObjects);
}
seenRightObjects.add(rightObject);
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right)) return false;
if (left.length !== right.length) return false;
return left.every((value, index) =>
sameResource(value, right[index], seenPairs),
);
}
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const leftKeys = Object.keys(leftRecord);
const rightKeys = Object.keys(rightRecord);
if (leftKeys.length !== rightKeys.length) return false;
return leftKeys.every(
(key) =>
Object.prototype.hasOwnProperty.call(rightRecord, key) &&
sameResource(leftRecord[key], rightRecord[key], seenPairs),
);
}
function visitComponents(nodes: Node[], visit: (component: Component) => void) {
@@ -505,15 +555,82 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
return next;
});
const [isLocked, setIsLocked] = useState(false);
const [historyState, setHistoryState] = useState<UiEditorHistoryState>({
canUndo: false,
canRedo: false,
});
const stateRef = useRef(state);
const isLockedRef = useRef(false);
const undoStackRef = useRef<Array<{ before: State; after: State }>>([]);
const redoStackRef = useRef<Array<{ before: State; after: State }>>([]);
const pendingHistoryBeforeRef = useRef<State | null>(null);
stateRef.current = state;
const commit = useCallback((nextState: State) => {
const syncHistoryState = useCallback(() => {
setHistoryState({
canUndo: undoStackRef.current.length > 0,
canRedo: redoStackRef.current.length > 0,
});
}, []);
const applyState = useCallback((nextState: State) => {
stateRef.current = nextState;
setState(nextState);
}, []);
const commit = useCallback(
(nextState: State) => {
const current = stateRef.current;
const before = pendingHistoryBeforeRef.current ?? current;
if (sameResource(before, nextState)) {
pendingHistoryBeforeRef.current = null;
return false;
}
undoStackRef.current.push({
before: cloneState(before),
after: nextState,
});
if (undoStackRef.current.length > MAX_HISTORY_LENGTH) {
undoStackRef.current.shift();
}
redoStackRef.current = [];
pendingHistoryBeforeRef.current = null;
syncHistoryState();
applyState(nextState);
return true;
},
[applyState, syncHistoryState],
);
const resetHistory = useCallback(() => {
undoStackRef.current = [];
redoStackRef.current = [];
pendingHistoryBeforeRef.current = null;
syncHistoryState();
}, [syncHistoryState]);
const undo = useCallback(() => {
if (isLockedRef.current) return false;
const entry = undoStackRef.current.pop();
if (!entry) return false;
pendingHistoryBeforeRef.current = null;
redoStackRef.current.push(entry);
applyState(cloneState(entry.before));
syncHistoryState();
return true;
}, [applyState, syncHistoryState]);
const redo = useCallback(() => {
if (isLockedRef.current) return false;
const entry = redoStackRef.current.pop();
if (!entry) return false;
pendingHistoryBeforeRef.current = null;
undoStackRef.current.push(entry);
applyState(cloneState(entry.after));
syncHistoryState();
return true;
}, [applyState, syncHistoryState]);
const guard = useCallback((): UiEditorOperationFailure | null => {
// Every semantic write exits before reading or committing State while locked.
return isLockedRef.current ? { ok: false, reason: 'locked' } : null;
@@ -534,6 +651,7 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
try {
return await operation(snapshot);
} finally {
pendingHistoryBeforeRef.current = null;
isLockedRef.current = false;
setIsLocked(false);
}
@@ -1397,22 +1515,38 @@ export function useUiEditorState(initialState: State = EMPTY_UI_EDITOR_STATE) {
const clearState = useCallback((): UiEditorOperationResult => {
const blocked = guard();
if (blocked) return blocked;
commit(cloneState(EMPTY_UI_EDITOR_STATE));
resetHistory();
applyState(cloneState(EMPTY_UI_EDITOR_STATE));
return { ok: true, value: undefined };
}, [commit, guard]);
}, [applyState, guard, resetHistory]);
const replaceState = useCallback(
(nextState: State) => {
(nextState: State, options: UiEditorReplaceStateOptions = {}) => {
const next = cloneState(nextState);
synchronizeDesignImageTrees(next);
commit(next);
if (options.history === 'reset') {
resetHistory();
applyState(next);
} else if (options.history === 'skip') {
if (!pendingHistoryBeforeRef.current) {
pendingHistoryBeforeRef.current = cloneState(stateRef.current);
}
applyState(next);
} else {
commit(next);
}
},
[commit],
[applyState, commit, resetHistory],
);
return {
state,
historyState,
undo,
redo,
resetHistory,
isLocked,
runWithStateLocked,
setImageName,
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { type RgbaColor, RgbaColorPicker } from 'react-colorful';
import type { FontSizing } from '../../../../../features/ui-editor/types/FontSizing';
@@ -21,19 +21,49 @@ export function TextPanel({
onChange,
}: TextEditorProps) {
const [colorOpen, setColorOpen] = useState(false);
const rgba: RgbaColor = {
const [colorDraft, setColorDraft] = useState<RgbaColor | null>(null);
const colorDraftRef = useRef<RgbaColor | null>(null);
const componentRef = useRef(component);
const onChangeRef = useRef(onChange);
componentRef.current = component;
onChangeRef.current = onChange;
const committedRgba: RgbaColor = {
r: component.color[0] ?? 255,
g: component.color[1] ?? 255,
b: component.color[2] ?? 255,
a: (component.color[3] ?? 255) / 255,
};
const rgba = colorDraft ?? committedRgba;
useEffect(() => {
colorDraftRef.current = null;
setColorDraft(null);
}, [component.color]);
useEffect(
() => () => {
const draft = colorDraftRef.current;
if (!draft) return;
onChangeRef.current({
...componentRef.current,
color: [draft.r, draft.g, draft.b, Math.round(draft.a * 255)],
});
},
[],
);
const bestFit =
'BestFit' in component.font_sizing ? component.font_sizing.BestFit : null;
const updateColor = (next: RgbaColor) =>
const commitColor = (next: RgbaColor = rgba) => {
if (!colorDraftRef.current) return;
colorDraftRef.current = null;
setColorDraft(null);
onChange({
...component,
color: [next.r, next.g, next.b, Math.round(next.a * 255)],
});
};
const toggleColorOpen = () => {
if (colorOpen) commitColor();
setColorOpen((open) => !open);
};
return (
<div className="space-y-3">
@@ -159,7 +189,7 @@ export function TextPanel({
type="button"
className="mt-1 flex h-9 w-full items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-white/65 px-2 text-left text-xs disabled:opacity-40"
disabled={readOnly}
onClick={() => setColorOpen((open) => !open)}
onClick={toggleColorOpen}
>
<span
className="size-5 rounded border border-black/15"
@@ -172,24 +202,35 @@ export function TextPanel({
</ComponentField>
{colorOpen && !readOnly ? (
<div className="absolute right-0 top-full z-20 mt-2 w-64 rounded-xl border border-(--platform-subpanel-border) bg-white p-3 shadow-xl">
<RgbaColorPicker color={rgba} onChange={updateColor} />
<div
onPointerCancel={() => {
colorDraftRef.current = null;
setColorDraft(null);
}}
>
<RgbaColorPicker
color={rgba}
onChange={(next) => {
colorDraftRef.current = next;
setColorDraft(next);
}}
onChangeEnd={commitColor}
/>
</div>
<ComponentNumberInput
label="Alpha"
min={0}
max={255}
step={1}
value={component.color[3]}
onChange={(event) =>
value={Math.round(rgba.a * 255)}
onChange={(event) => {
colorDraftRef.current = null;
setColorDraft(null);
onChange({
...component,
color: [
component.color[0],
component.color[1],
component.color[2],
Number(event.target.value),
],
})
}
color: [rgba.r, rgba.g, rgba.b, Number(event.target.value)],
});
}}
/>
</div>
) : null}
@@ -24,6 +24,7 @@ import {
} from 'react';
import { findNodePageContext } from '../../../../features/ui-editor/nodeTransformGeometry';
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';
@@ -48,6 +49,9 @@ export function PreviewWorkspace({
const [renderMode, setRenderMode] =
useState<UiEditorRenderMode>('editor-overlay');
const [showFrame, setShowFrame] = useState(false);
const [previewTransforms, setPreviewTransforms] = useState<
ReadonlyMap<NodeId, Node['layout']['transform']>
>(new Map());
const [contextMenu, setContextMenu] = useState<{
nodeId: NodeId;
x: number;
@@ -55,6 +59,22 @@ export function PreviewWorkspace({
isPageRoot: boolean;
} | null>(null);
const tree = canvas.tree ?? null;
const updatePreviewTransform = useCallback(
(nodeId: NodeId, transform: Node['layout']['transform'] | null) => {
setPreviewTransforms((current) => {
if (!transform && !current.has(nodeId)) return current;
if (transform && current.get(nodeId) === transform) return current;
const next = new Map(current);
if (transform) next.set(nodeId, transform);
else next.delete(nodeId);
return next;
});
},
[],
);
useEffect(() => {
setPreviewTransforms(new Map());
}, [activeImageId]);
const activeImagePixelWidth = activeImage?.pixel_size[0];
const activeImagePixelHeight = activeImage?.pixel_size[1];
const activeImagePixelsPerUnit = activeImage?.pixels_per_unit;
@@ -87,7 +107,9 @@ export function PreviewWorkspace({
logicalSize,
spaceHeld,
tree,
keepChildrenUnchanged: canvas.keepChildrenUnchanged,
viewportRef,
onPreviewTransform: updatePreviewTransform,
});
const {
onNodePointerDown,
@@ -382,6 +404,7 @@ export function PreviewWorkspace({
renderMode={renderMode}
showFrame={showFrame}
hiddenNodeIds={canvas.hiddenNodeIds}
previewTransforms={previewTransforms}
selectedNodeId={canvas.selectedNodeId}
resources={{
previewUrls,
@@ -32,6 +32,7 @@ type UiTreeRendererProps = {
renderMode: UiEditorRenderMode;
showFrame: boolean;
hiddenNodeIds: ReadonlySet<NodeId>;
previewTransforms?: ReadonlyMap<NodeId, UiNode['layout']['transform']>;
selectedNodeId: NodeId | null;
resources: PreviewComponentResources;
onSelectNode: (id: NodeId) => void;
@@ -70,6 +71,11 @@ const RESIZE_HANDLES: ReadonlyArray<{
{ id: 'w', left: '0%', top: '50%', cursor: 'ew-resize' },
];
const EMPTY_PREVIEW_TRANSFORMS: ReadonlyMap<
NodeId,
UiNode['layout']['transform']
> = new Map();
function RenderNode({
node,
isRoot,
@@ -77,6 +83,7 @@ function RenderNode({
renderMode,
showFrame,
hiddenNodeIds,
previewTransforms,
selectedNodeId,
resources,
onSelectNode,
@@ -94,14 +101,16 @@ function RenderNode({
isRoot?: boolean;
parentContainer?: UiNode['layout']['container'];
}) {
const activePreviewTransforms = previewTransforms ?? EMPTY_PREVIEW_TRANSFORMS;
if (hiddenNodeIds.has(node.id)) return null;
const previewTransform = activePreviewTransforms.get(node.id);
const layout = previewTransform
? { ...node.layout, transform: previewTransform }
: node.layout;
let geometry;
try {
geometry = controlLayoutToPreviewCss(
node.layout,
parentContainer !== undefined,
);
geometry = controlLayoutToPreviewCss(layout, parentContainer !== undefined);
} catch {
// Keep malformed nodes isolated from the rest of the tree.
return null;
@@ -120,7 +129,7 @@ function RenderNode({
style={{
...geometry,
...(parentContainer
? childInContainerToPreviewCss(node.layout, parentContainer)
? childInContainerToPreviewCss(layout, parentContainer)
: {}),
...containerToPreviewCss(node.layout.container),
...(isFrameVisible
@@ -189,6 +198,7 @@ function RenderNode({
renderMode={renderMode}
showFrame={showFrame}
hiddenNodeIds={hiddenNodeIds}
previewTransforms={activePreviewTransforms}
selectedNodeId={selectedNodeId}
resources={resources}
onSelectNode={onSelectNode}
@@ -9,9 +9,12 @@ import {
import {
findNodePageContext,
type PageRect,
pageRectFromSize,
type ResizeAxis,
type ResizeHandle,
resizePageRect,
resolveChildrenTransformsForParentRect,
resolvePageRect,
resolveProportionalResizeAxis,
setOffsetsForPageRect,
} from '../../../../features/ui-editor/nodeTransformGeometry';
@@ -29,6 +32,8 @@ type GestureBase = {
startClientY: number;
startTransform: UiNode['layout']['transform'];
hasMoved: boolean;
pendingTransform?: UiNode['layout']['transform'];
previewNodeIds: string[];
};
type ActiveGesture =
@@ -78,26 +83,100 @@ function releasePointer(target: HTMLDivElement, pointerId: number) {
}
}
function previewNodeIds(
tree: UITree | null,
nodeId: string,
logicalSize: { width: number; height: number } | null,
keepChildrenUnchanged: boolean,
) {
if (!keepChildrenUnchanged || !tree || !logicalSize) return [nodeId];
const context = findNodePageContext(
tree.root,
nodeId,
pageRectFromSize([logicalSize.width, logicalSize.height]),
);
return context
? [nodeId, ...context.node.children.map((child) => child.id)]
: [nodeId];
}
function emitPreviewTransforms(
gesture: ActiveGesture,
tree: UITree | null,
logicalSize: { width: number; height: number } | null,
keepChildrenUnchanged: boolean,
onPreviewTransform: (
nodeId: string,
transform: UiNode['layout']['transform'] | null,
) => void,
) {
onPreviewTransform(gesture.nodeId, gesture.pendingTransform ?? null);
if (
!gesture.pendingTransform ||
!keepChildrenUnchanged ||
!tree ||
!logicalSize
) {
return;
}
const context = findNodePageContext(
tree.root,
gesture.nodeId,
pageRectFromSize([logicalSize.width, logicalSize.height]),
);
if (!context) return;
const newNodeRect = resolvePageRect(
gesture.pendingTransform,
context.parentRect,
);
if (!isFiniteRect(newNodeRect)) return;
for (const child of resolveChildrenTransformsForParentRect(
context.node.children,
context.rect,
newNodeRect,
)) {
onPreviewTransform(child.id, child.transform);
}
}
export function useNodeTransformInteraction({
activeImageId,
canvas,
logicalSize,
spaceHeld,
tree,
keepChildrenUnchanged,
viewportRef,
onPreviewTransform,
}: {
activeImageId: UiEditorCanvasProjection['activeImageId'];
canvas: Pick<UiEditorCanvasProjection, 'selectNode' | 'updateNodeTransform'>;
logicalSize: { width: number; height: number } | null;
spaceHeld: boolean;
tree: UITree | null;
keepChildrenUnchanged: boolean;
viewportRef: RefObject<ViewportScale>;
onPreviewTransform?: (
nodeId: string,
transform: UiNode['layout']['transform'] | null,
) => void;
}) {
const activeGestureRef = useRef<ActiveGesture | null>(null);
// Keep cleanup stable while still invoking the latest preview callback.
const onPreviewTransformRef = useRef(onPreviewTransform);
onPreviewTransformRef.current = onPreviewTransform;
const cancelGesture = useCallback(() => {
const gesture = activeGestureRef.current;
if (gesture) releasePointer(gesture.target, gesture.pointerId);
if (gesture) {
releasePointer(gesture.target, gesture.pointerId);
onPreviewTransformRef.current?.(gesture.nodeId, null);
for (const nodeId of gesture.previewNodeIds) {
if (nodeId !== gesture.nodeId) {
onPreviewTransformRef.current?.(nodeId, null);
}
}
}
activeGestureRef.current = null;
}, []);
@@ -151,9 +230,22 @@ export function useNodeTransformInteraction({
startClientY: event.clientY,
startTransform: structuredClone(node.layout.transform),
hasMoved: false,
previewNodeIds: previewNodeIds(
tree,
node.id,
logicalSize,
keepChildrenUnchanged,
),
};
},
[activeImageId, canvas, spaceHeld, tree?.root.id],
[
activeImageId,
canvas,
keepChildrenUnchanged,
logicalSize,
spaceHeld,
tree,
],
);
const onNodePointerMove = useCallback(
@@ -185,18 +277,45 @@ export function useNodeTransformInteraction({
cancelGesture();
return;
}
canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform);
gesture.pendingTransform = nextTransform;
emitPreviewTransforms(
gesture,
tree,
logicalSize,
keepChildrenUnchanged,
(nodeId, transform) =>
onPreviewTransformRef.current?.(nodeId, transform),
);
},
[acceptsGestureEvent, cancelGesture, canvas, viewportRef],
[
acceptsGestureEvent,
cancelGesture,
keepChildrenUnchanged,
logicalSize,
tree,
viewportRef,
],
);
const onNodePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!acceptsGestureEvent(event)) return;
event.stopPropagation();
const gesture = activeGestureRef.current;
if (
event.type !== 'pointercancel' &&
gesture?.hasMoved &&
gesture.pendingTransform
) {
canvas.updateNodeTransform(
gesture.treeId,
gesture.nodeId,
gesture.pendingTransform,
);
}
cancelGesture();
},
[acceptsGestureEvent, cancelGesture],
[acceptsGestureEvent, cancelGesture, canvas],
);
const onNodeResizePointerDown = useCallback(
@@ -251,9 +370,22 @@ export function useNodeTransformInteraction({
parentRect: context.parentRect,
ratioAxis: null,
hasMoved: false,
previewNodeIds: previewNodeIds(
tree,
node.id,
logicalSize,
keepChildrenUnchanged,
),
};
},
[activeImageId, canvas, logicalSize, spaceHeld, tree],
[
activeImageId,
canvas,
keepChildrenUnchanged,
logicalSize,
spaceHeld,
tree,
],
);
const onNodeResizePointerMove = useCallback(
@@ -310,9 +442,24 @@ export function useNodeTransformInteraction({
return;
}
gesture.hasMoved = true;
canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform);
gesture.pendingTransform = nextTransform;
emitPreviewTransforms(
gesture,
tree,
logicalSize,
keepChildrenUnchanged,
(nodeId, transform) =>
onPreviewTransformRef.current?.(nodeId, transform),
);
},
[acceptsGestureEvent, cancelGesture, canvas, viewportRef],
[
acceptsGestureEvent,
cancelGesture,
keepChildrenUnchanged,
logicalSize,
tree,
viewportRef,
],
);
return {
@@ -1,4 +1,4 @@
import { ChevronLeft } from 'lucide-react';
import { ChevronLeft, Redo2, Undo2 } from 'lucide-react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { ThemedModal } from '../../components/modal/ThemedModal';
@@ -77,11 +77,44 @@ export default function UiEditorPage({
Boolean(session.save.loadError) ||
session.save.persistedRevision === null ||
session.save.isLocked;
const historyUndo = session.history.undo;
const historyRedo = session.history.redo;
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();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [historyRedo, historyUndo]);
async function save(afterReturn = false) {
if (await session.save.save()) {
if (afterReturn) {
@@ -155,6 +188,26 @@ export default function UiEditorPage({
</button>
<strong className="text-sm">{resourceLabel ?? 'UI 设计'}</strong>
<div className="game-workbench-editor-actions">
<button
type="button"
className="inline-flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) px-2.5 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-40"
aria-label="撤销"
disabled={!session.history.canUndo || session.save.isLocked}
onClick={() => session.history.undo()}
>
<Undo2 size={16} aria-hidden="true" />
</button>
<button
type="button"
className="inline-flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) px-2.5 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-40"
aria-label="重做"
disabled={!session.history.canRedo || session.save.isLocked}
onClick={() => session.history.redo()}
>
<Redo2 size={16} aria-hidden="true" />
</button>
{walletEntry ? (
<div className="game-workbench-editor-wallet">{walletEntry}</div>
) : null}
@@ -274,7 +274,7 @@ export function useUiEditorSession(
k88936 marked this conversation as resolved
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
.load(resourceId)
.then(({ state, revision }) => {
if (!cancelled) {
replaceEditorState(state);
k88936 marked this conversation as resolved
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
replaceEditorState(state, { history: 'reset' });
k88936 marked this conversation as resolved
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
const ids = Object.keys(
state.ui_design_images,
).sort() as UIDesignImageId[];
@@ -1028,7 +1028,9 @@ export function useUiEditorSession(
k88936 marked this conversation as resolved
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
spriteIds,
});
current = applyBindingResult(current, result);
editor.replaceState(current);
k88936 marked this conversation as resolved
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
editor.replaceState(current, {
k88936 marked this conversation as resolved
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
history: index < batches.length - 1 ? 'skip' : 'record',
k88936 marked this conversation as resolved
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
});
k88936 marked this conversation as resolved
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
}
setBindingStatus(
`组件绑定完成(${batches.length}/${batches.length})。`,
@@ -1214,6 +1216,7 @@ export function useUiEditorSession(
k88936 marked this conversation as resolved
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
tree: treeForActiveImage ?? null,
selectedNode: selectedNodeContext?.node ?? null,
selectedNodeId,
keepChildrenUnchanged,
k88936 marked this conversation as resolved
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
hiddenNodeIds,
focusRequest,
status,
@@ -1228,6 +1231,11 @@ export function useUiEditorSession(
k88936 marked this conversation as resolved
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
deleteNode,
openClearDialog: () => setClearOpen(true),
},
history: {
k88936 marked this conversation as resolved
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
...editor.historyState,
k88936 marked this conversation as resolved
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
undo: editor.undo,
k88936 marked this conversation as resolved
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
redo: editor.redo,
k88936 marked this conversation as resolved
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
},
k88936 marked this conversation as resolved
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
inspector: {
isLocked: editor.isLocked,
projectPath,
k88936 marked this conversation as resolved
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
Review

bindComponents 每个 batch 都调用 editor.replaceState(current),而 replaceState 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 history: 'skip',最后统一记录一次。

`bindComponents` 每个 batch 都调用 `editor.replaceState(current)`,而 `replaceState` 默认记录历史。因此一次批量绑定会产生多个撤销步骤;请在批处理期间使用 `history: 'skip'`,最后统一记录一次。
@@ -603,4 +603,149 @@ describe('useUiEditorState', () => {
}),
]);
});
it('records, undoes, redoes, and clears redo after a new edit', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: { page: image('Page') },
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
result.current.setImageName('page', '第一次');
});
expect(result.current.historyState).toEqual({
canUndo: true,
canRedo: false,
});
act(() => {
expect(result.current.undo()).toBe(true);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
'Page',
);
expect(result.current.historyState).toEqual({
canUndo: false,
canRedo: true,
});
act(() => {
expect(result.current.redo()).toBe(true);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
'第一次',
);
act(() => {
result.current.setImageName('page', '第二次');
expect(result.current.redo()).toBe(false);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
'第二次',
);
});
it('does not record no-op edits and resets history when replacing loaded state', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: { page: image('Page') },
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
result.current.setImageName('page', 'Page');
});
expect(result.current.historyState.canUndo).toBe(false);
act(() => {
result.current.setImageName('page', '编辑后');
result.current.replaceState(initial, { history: 'reset' });
});
expect(result.current.historyState).toEqual({
canUndo: false,
canRedo: false,
});
act(() => {
result.current.setImageName('page', '清空前');
result.current.clearState();
});
expect(result.current.state).toEqual(EMPTY_UI_EDITOR_STATE);
expect(result.current.historyState).toEqual({
canUndo: false,
canRedo: false,
});
});
it('records each replacement as an independent history entry', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: { page: image('Page') },
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
result.current.replaceState({
...initial,
ui_design_images: { page: image('中间') },
});
result.current.replaceState({
...initial,
ui_design_images: { page: image('最终') },
});
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
'最终',
);
act(() => {
expect(result.current.undo()).toBe(true);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
'中间',
);
});
it('records one history entry after skipped replacement batches', () => {
const initial: State = {
...structuredClone(EMPTY_UI_EDITOR_STATE),
ui_design_images: { page: image('Page') },
};
const { result } = renderHook(() => useUiEditorState(initial));
act(() => {
result.current.replaceState(
{
...initial,
ui_design_images: { page: image('第一批') },
},
{ history: 'skip' },
);
result.current.replaceState(
{
...initial,
ui_design_images: { page: image('最终') },
},
{ history: 'record' },
);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
'最终',
);
expect(result.current.historyState).toEqual({
canUndo: true,
canRedo: false,
});
act(() => {
expect(result.current.undo()).toBe(true);
});
expect(result.current.state.ui_design_images.page?.metadata.name).toBe(
'Page',
);
expect(result.current.historyState).toEqual({
canUndo: false,
canRedo: true,
});
});
});
@@ -44,6 +44,17 @@ const pageTree: UITree = {
root: node('root', [child]),
};
function transformedNode(
id: string,
transform: UiNode['layout']['transform'],
children: UiNode[] = [],
) {
return {
...node(id, children),
layout: { ...node(id).layout, transform },
};
}
function gestureTarget() {
const target = document.createElement('div');
Object.assign(target, {
@@ -83,10 +94,17 @@ function renderInteraction({
activeImageId = 'page',
scale = 1,
tree = pageTree,
keepChildrenUnchanged = false,
onPreviewTransform,
}: {
activeImageId?: string | null;
scale?: number;
tree?: UITree | null;
keepChildrenUnchanged?: boolean;
onPreviewTransform?: (
nodeId: string,
transform: UiNode['layout']['transform'] | null,
) => void;
} = {}) {
const canvasProjection = canvas();
const viewportRef = { current: { scale } };
@@ -99,6 +117,8 @@ function renderInteraction({
spaceHeld: false,
tree: currentTree,
viewportRef,
keepChildrenUnchanged,
onPreviewTransform,
}),
{ initialProps: { imageId: activeImageId, currentTree: tree } },
);
@@ -106,6 +126,97 @@ function renderInteraction({
}
describe('useNodeTransformInteraction', () => {
it('previews stable child page rectangles when the parent moves', () => {
const nestedChild = transformedNode('child', {
anchor_min: [0, 0],
anchor_max: [0, 0],
offset_min: [10, 10],
offset_max: [60, 50],
});
const parent = transformedNode(
'parent',
{
anchor_min: [0, 0],
anchor_max: [0, 0],
offset_min: [20, 20],
offset_max: [120, 100],
},
[nestedChild],
);
const tree: UITree = {
src_ui_design: 'page',
root: node('root', [parent]),
};
const onPreviewTransform = vi.fn();
const { result } = renderInteraction({
tree,
keepChildrenUnchanged: true,
onPreviewTransform,
});
const target = gestureTarget();
act(() => {
result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), parent);
result.current.onNodePointerMove(pointerEvent(target, 1, 10, 5));
});
expect(onPreviewTransform).toHaveBeenNthCalledWith(
1,
'parent',
expect.objectContaining({ offset_min: [30, 25], offset_max: [130, 105] }),
);
expect(onPreviewTransform).toHaveBeenNthCalledWith(
2,
'child',
expect.objectContaining({ offset_min: [0, 5], offset_max: [50, 45] }),
);
});
it('previews stable child page rectangles when the parent resizes', () => {
const nestedChild = transformedNode('child', {
anchor_min: [0, 0],
anchor_max: [0, 0],
offset_min: [10, 10],
offset_max: [60, 50],
});
const parent = transformedNode(
'parent',
{
anchor_min: [0, 0],
anchor_max: [0, 0],
offset_min: [20, 20],
offset_max: [120, 100],
},
[nestedChild],
);
const tree: UITree = {
src_ui_design: 'page',
root: node('root', [parent]),
};
const onPreviewTransform = vi.fn();
const { result } = renderInteraction({
tree,
keepChildrenUnchanged: true,
onPreviewTransform,
});
const target = gestureTarget();
act(() => {
result.current.onNodeResizePointerDown(
pointerEvent(target, 1, 0, 0),
parent,
'nw',
);
result.current.onNodeResizePointerMove(pointerEvent(target, 1, 10, 5));
});
expect(onPreviewTransform).toHaveBeenNthCalledWith(
2,
'child',
expect.objectContaining({ offset_min: [0, 5], offset_max: [50, 45] }),
);
});
it('makes drag and resize mutually exclusive, then permits the next gesture', () => {
const { result, canvasProjection } = renderInteraction();
const dragTarget = gestureTarget();
@@ -125,7 +236,7 @@ describe('useNodeTransformInteraction', () => {
});
expect(resizeTarget.setPointerCapture).not.toHaveBeenCalled();
expect(canvasProjection.updateNodeTransform).toHaveBeenCalledTimes(1);
expect(canvasProjection.updateNodeTransform).not.toHaveBeenCalled();
act(() => {
result.current.onNodePointerUp(pointerEvent(dragTarget, 1, 12, 8));
@@ -135,6 +246,7 @@ describe('useNodeTransformInteraction', () => {
'se',
);
});
expect(canvasProjection.updateNodeTransform).toHaveBeenCalledTimes(1);
expect(resizeTarget.setPointerCapture).toHaveBeenCalledWith(2);
});
+1
View File
@@ -32,6 +32,7 @@
- [UI 编辑器 Godot 容器布局](./technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md)
- [UI 编辑器子节点显示规则](./technical/【技术方案】UI编辑器子节点显示规则-2026-08-18.md)
- [UI 编辑会话模块边界](./technical/【前端架构】UI编辑会话模块边界-2026-08-19.md)
- [UI 编辑器撤销重做规范](./【UI编辑器】撤销重做规范-2026-09-03.md)
## 图片画布与媒体
@@ -0,0 +1,14 @@
# UI 编辑器拖动变换提交边界
## 当前约定
UI 编辑器预览中的节点拖动和缩放在指针移动期间只更新预览层的临时变换,不写入编辑器 State。指针松开时才把最后一次有效变换提交到 State,因此一次拖动或缩放只产生一次正式编辑更新。
指针取消、页面切换、树切换、组件卸载或没有超过拖动阈值时,不提交变换,并清理临时预览值。指针松开后的最终变换属于正常 State 修改,会参与脏状态、保存和后端持久化;仅拖动期间的临时变换不会进入这些流程。资产文件也不会因该交互被删除。
## 实现边界
- `useNodeTransformInteraction` 保存手势起始变换和最后一次有效变换。
- `UiTreeRenderer` 通过 `previewTransforms` 渲染临时变换。
- `canvas.updateNodeTransform` 仅在 `pointerup` 提交,`pointercancel` 不提交。
- 拖动和缩放继续共用单指针捕获与有限数校验。
@@ -0,0 +1,69 @@
# UI 编辑器撤销与重做规范
## 目标
UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被撤销的编辑,降低误操作返工成本,同时保持现有保存、AI 工作流和资产文件行为不变。
## 适用范围
撤销历史属于当前 UI 编辑会话,历史只保存可序列化的编辑器 `State` 快照,不保存页面临时状态。
纳入历史的操作:
- 设计图名称、描述、角色和从属关系修改;
- 设计图、精灵、字体资源的新增、删除和元数据修改;
- 节点新增、删除、移动、Transform、Layout、元数据、子节点显示模式修改;
- 组件新增、删除、排序和字段修改;
- AI suggest、recognize、merge,以及批量导入、批量删除等批量 State 修改,整次成功调用作为一条记录;
- bind 按后端批次逐次提交,每个成功 batch 作为一条独立记录,便于逐批撤销;
- 节点拖动或缩放,按一次按下到松开的连续操作作为一条记录。
不纳入历史的操作:
- 选择项、隐藏节点、工作流步骤、面板展开状态和画布视口等 UI 临时状态;
- 打开/切换项目、服务端重新加载和清空编辑器;这些操作替换 State 后重置历史;
- 保存、自动保存、生成代码、发布请求本身;撤销只改变本地 State,后续保存才同步远端;
- 素材上传、AI 生成等已发生的外部副作用;若副作用同时落地了本地 State,只撤销本地 State 变化;
- no-op、锁定、校验失败或目标不存在的操作。
导入资源被撤销时只回退编辑器 State 中的资源记录和引用,不删除已经写入磁盘的资产文件;重做恢复原资源 ID 与文件引用。
## 历史模型
- 栈按会话全局维护,最多保留最近 100 条事务;超限丢弃最旧记录。
- 每条记录保存 `before``after` 的完整结构化 State 快照,快照不复制二进制文件内容。
- 提交前后快照相同则不产生记录。
- 撤销将当前 State 恢复为记录的 `before`,并把记录移入 redo 栈;重做恢复 `after`
- 撤销后发生新的有效编辑时清空 redo 栈。
- 撤销/重做恢复 State 时不得再次写入历史。
## 事务边界
- 普通字段、按钮和列表操作一次成功调用对应一条记录。
- 节点拖动/缩放期间只更新预览层临时变换;松开时提交最终变换并生成一条记录。取消、卸载、切换资源、未越过阈值或无变化不提交。
- 每次有效 `commit``replaceState` 都直接生成一条记录;bind 的每个成功 batch 独立提交。失败 batch 不产生记录,已完成的前序 batch 保留。
- 颜色选择器、九宫格边界拖动等连续控件在交互期间使用本地 draft 预览,释放或确认时一次提交。
## 用户入口
- 桌面端页面工具栏提供撤销和重做按钮。
- 非文本编辑目标聚焦编辑器时支持 `Cmd/Ctrl+Z` 撤销、`Cmd/Ctrl+Shift+Z``Ctrl+Y` 重做。
- `input``textarea``select``contenteditable` 以及按钮/链接等控件交给浏览器原生行为,不拦截文本撤销。
- 无可撤销或重做记录时按钮禁用,并提供可访问名称。
## 脏状态与选择
撤销和重做恢复的 State 继续参与现有 dirty 判定、保存和后端持久化。历史快照不包含当前设计图、节点选择、隐藏集合或视口;恢复后若当前选择已不存在,页面清理无效选择并保持安全空态。
## 验收标准
1. 单次字段编辑可撤销和重做。
2. 连续多次编辑按逆序撤销。
3. 节点拖动/缩放一次手势只产生一条记录,取消和零变化不产生记录。
4. AI suggest/recognize/merge 一次调用只产生一条记录;bind 每个成功 batch 产生一条记录,失败不产生该 batch 记录。
5. 撤销后新编辑清空 redo。
6. 加载/切换/清空重置历史;保存/自动保存不清空历史。
7. 撤销/重做资源导入不删除资产文件,并恢复原资源引用。
8. 工具栏按钮、禁用态和桌面快捷键可用,文本控件保留原生撤销。
9. no-op、锁定、校验失败和不存在目标不进入历史。
10. 颜色选择器和九宫格边界拖动不会按每个 pointer move 写入 State。