调整 UI 编辑器节点变换为松开时提交
拖动和缩放期间仅更新预览临时变换 指针松开时提交最终变换,取消操作不写入 State 补充 UI 编辑器拖动变换提交边界文档
This commit is contained in:
@@ -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,20 @@ export function PreviewWorkspace({
|
||||
isPageRoot: boolean;
|
||||
} | null>(null);
|
||||
const tree = canvas.tree ?? null;
|
||||
const updatePreviewTransform = useCallback(
|
||||
(nodeId: NodeId, transform: Node['layout']['transform'] | null) => {
|
||||
setPreviewTransforms((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;
|
||||
@@ -88,6 +106,7 @@ export function PreviewWorkspace({
|
||||
spaceHeld,
|
||||
tree,
|
||||
viewportRef,
|
||||
onPreviewTransform: updatePreviewTransform,
|
||||
});
|
||||
const {
|
||||
onNodePointerDown,
|
||||
@@ -382,6 +401,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;
|
||||
@@ -77,6 +78,7 @@ function RenderNode({
|
||||
renderMode,
|
||||
showFrame,
|
||||
hiddenNodeIds,
|
||||
previewTransforms,
|
||||
selectedNodeId,
|
||||
resources,
|
||||
onSelectNode,
|
||||
@@ -94,14 +96,15 @@ function RenderNode({
|
||||
isRoot?: boolean;
|
||||
parentContainer?: UiNode['layout']['container'];
|
||||
}) {
|
||||
const activePreviewTransforms = previewTransforms ?? new Map();
|
||||
if (hiddenNodeIds.has(node.id)) return null;
|
||||
const layout = activePreviewTransforms.has(node.id)
|
||||
? { ...node.layout, transform: activePreviewTransforms.get(node.id)! }
|
||||
: 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 +123,7 @@ function RenderNode({
|
||||
style={{
|
||||
...geometry,
|
||||
...(parentContainer
|
||||
? childInContainerToPreviewCss(node.layout, parentContainer)
|
||||
? childInContainerToPreviewCss(layout, parentContainer)
|
||||
: {}),
|
||||
...containerToPreviewCss(node.layout.container),
|
||||
...(isFrameVisible
|
||||
@@ -189,6 +192,7 @@ function RenderNode({
|
||||
renderMode={renderMode}
|
||||
showFrame={showFrame}
|
||||
hiddenNodeIds={hiddenNodeIds}
|
||||
previewTransforms={activePreviewTransforms}
|
||||
selectedNodeId={selectedNodeId}
|
||||
resources={resources}
|
||||
onSelectNode={onSelectNode}
|
||||
|
||||
+30
-7
@@ -29,6 +29,7 @@ type GestureBase = {
|
||||
startClientY: number;
|
||||
startTransform: UiNode['layout']['transform'];
|
||||
hasMoved: boolean;
|
||||
pendingTransform?: UiNode['layout']['transform'];
|
||||
};
|
||||
|
||||
type ActiveGesture =
|
||||
@@ -85,6 +86,7 @@ export function useNodeTransformInteraction({
|
||||
spaceHeld,
|
||||
tree,
|
||||
viewportRef,
|
||||
onPreviewTransform,
|
||||
}: {
|
||||
activeImageId: UiEditorCanvasProjection['activeImageId'];
|
||||
canvas: Pick<UiEditorCanvasProjection, 'selectNode' | 'updateNodeTransform'>;
|
||||
@@ -92,14 +94,21 @@ export function useNodeTransformInteraction({
|
||||
spaceHeld: boolean;
|
||||
tree: UITree | null;
|
||||
viewportRef: RefObject<ViewportScale>;
|
||||
onPreviewTransform?: (
|
||||
nodeId: string,
|
||||
transform: UiNode['layout']['transform'] | null,
|
||||
) => void;
|
||||
}) {
|
||||
const activeGestureRef = useRef<ActiveGesture | null>(null);
|
||||
|
||||
const cancelGesture = useCallback(() => {
|
||||
const gesture = activeGestureRef.current;
|
||||
if (gesture) releasePointer(gesture.target, gesture.pointerId);
|
||||
if (gesture) {
|
||||
releasePointer(gesture.target, gesture.pointerId);
|
||||
onPreviewTransform?.(gesture.nodeId, null);
|
||||
}
|
||||
activeGestureRef.current = null;
|
||||
}, []);
|
||||
}, [onPreviewTransform]);
|
||||
|
||||
useEffect(() => cancelGesture, [cancelGesture]);
|
||||
|
||||
@@ -185,18 +194,31 @@ export function useNodeTransformInteraction({
|
||||
cancelGesture();
|
||||
return;
|
||||
}
|
||||
canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform);
|
||||
gesture.pendingTransform = nextTransform;
|
||||
onPreviewTransform?.(gesture.nodeId, nextTransform);
|
||||
},
|
||||
[acceptsGestureEvent, cancelGesture, canvas, viewportRef],
|
||||
[acceptsGestureEvent, cancelGesture, onPreviewTransform, 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(
|
||||
@@ -310,9 +332,10 @@ export function useNodeTransformInteraction({
|
||||
return;
|
||||
}
|
||||
gesture.hasMoved = true;
|
||||
canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform);
|
||||
gesture.pendingTransform = nextTransform;
|
||||
onPreviewTransform?.(gesture.nodeId, nextTransform);
|
||||
},
|
||||
[acceptsGestureEvent, cancelGesture, canvas, viewportRef],
|
||||
[acceptsGestureEvent, cancelGesture, onPreviewTransform, viewportRef],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# UI 编辑器拖动变换提交边界
|
||||
|
||||
## 当前约定
|
||||
|
||||
UI 编辑器预览中的节点拖动和缩放在指针移动期间只更新预览层的临时变换,不写入编辑器 State。指针松开时才把最后一次有效变换提交到 State,因此一次拖动或缩放只产生一次正式编辑更新。
|
||||
|
||||
指针取消、页面切换、树切换、组件卸载或没有超过拖动阈值时,不提交变换,并清理临时预览值。指针松开后的最终变换属于正常 State 修改,会参与脏状态、保存和后端持久化;仅拖动期间的临时变换不会进入这些流程。资产文件也不会因该交互被删除。
|
||||
|
||||
## 实现边界
|
||||
|
||||
- `useNodeTransformInteraction` 保存手势起始变换和最后一次有效变换。
|
||||
- `UiTreeRenderer` 通过 `previewTransforms` 渲染临时变换。
|
||||
- `canvas.updateNodeTransform` 仅在 `pointerup` 提交,`pointercancel` 不提交。
|
||||
- 拖动和缩放继续共用单指针捕获与有限数校验。
|
||||
Reference in New Issue
Block a user