修正预览拖动使用当前选中节点

拖动手势优先更新按下前已选中的节点

补充跨节点拖动回归测试并同步交互约定
This commit is contained in:
2026-09-03 14:23:27 +08:00
parent 8d118ec1d7
commit f10fc2f032
4 changed files with 53 additions and 5 deletions
@@ -88,6 +88,7 @@ export function PreviewWorkspace({
spaceHeld,
tree,
previewRef: viewportElementRef,
selectedNodeId: canvas.selectedNodeId,
viewportRef,
});
const {
@@ -73,6 +73,15 @@ function isFiniteTransform(transform: UiNode['layout']['transform']) {
);
}
function findNodeById(node: UiNode, nodeId: string): UiNode | null {
if (node.id === nodeId) return node;
for (const child of node.children) {
const match = findNodeById(child, nodeId);
if (match) return match;
}
return null;
}
function releasePointer(target: HTMLDivElement, pointerId: number) {
if (target.hasPointerCapture(pointerId)) {
target.releasePointerCapture(pointerId);
@@ -113,6 +122,7 @@ export function useNodeTransformInteraction({
tree,
viewportRef,
previewRef,
selectedNodeId,
}: {
activeImageId: UiEditorCanvasProjection['activeImageId'];
canvas: Pick<UiEditorCanvasProjection, 'selectNode' | 'updateNodeTransform'>;
@@ -121,6 +131,7 @@ export function useNodeTransformInteraction({
tree: UITree | null;
viewportRef: RefObject<ViewportScale>;
previewRef?: RefObject<HTMLElement | null>;
selectedNodeId?: string | null;
}) {
const activeGestureRef = useRef<ActiveGesture | null>(null);
const suppressNextNodeClickRef = useRef(false);
@@ -174,23 +185,28 @@ export function useNodeTransformInteraction({
) {
return;
}
const selectedNode =
selectedNodeId && tree ? findNodeById(tree.root, selectedNodeId) : null;
const dragNode =
selectedNode && selectedNode.id !== tree?.root.id ? selectedNode : node;
if (!isFiniteTransform(dragNode.layout.transform)) return;
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
suppressNextNodeClickRef.current = false;
activeGestureRef.current = {
kind: 'drag',
treeId: activeImageId,
nodeId: node.id,
nodeId: dragNode.id,
pointerId: event.pointerId,
target: event.currentTarget,
startClientX: event.clientX,
startClientY: event.clientY,
startTransform: structuredClone(node.layout.transform),
startTransform: structuredClone(dragNode.layout.transform),
hitNodeId: resolveHitNodeId(event, node.id),
hasMoved: false,
};
},
[activeImageId, spaceHeld, tree?.root.id],
[activeImageId, selectedNodeId, spaceHeld, tree],
);
const onNodePointerMove = useCallback(
@@ -79,7 +79,10 @@ function canvas() {
return {
selectNode: vi.fn(),
updateNodeTransform: vi.fn(),
} as Pick<UiEditorCanvasProjection, 'selectNode' | 'updateNodeTransform'>;
selectedNodeId: null as string | null,
} as Pick<UiEditorCanvasProjection, 'selectNode' | 'updateNodeTransform'> & {
selectedNodeId: string | null;
};
}
function renderInteraction({
@@ -87,13 +90,16 @@ function renderInteraction({
scale = 1,
tree = pageTree,
previewRef,
selectedNodeId = null,
}: {
activeImageId?: string | null;
scale?: number;
tree?: UITree | null;
previewRef?: MutableRefObject<HTMLElement | null>;
selectedNodeId?: string | null;
} = {}) {
const canvasProjection = canvas();
canvasProjection.selectedNodeId = selectedNodeId;
const viewportRef = { current: { scale } };
const hook = renderHook(
({ imageId, currentTree }) =>
@@ -104,6 +110,7 @@ function renderInteraction({
spaceHeld: false,
tree: currentTree,
previewRef,
selectedNodeId,
viewportRef,
}),
{ initialProps: { imageId: activeImageId, currentTree: tree } },
@@ -112,6 +119,30 @@ function renderInteraction({
}
describe('useNodeTransformInteraction', () => {
it('moves the current selection when dragging another node', () => {
const selected = node('selected');
const treeWithSelection: UITree = {
src_ui_design: 'page',
root: node('root', [selected, child]),
};
const { result, canvasProjection } = renderInteraction({
tree: treeWithSelection,
selectedNodeId: 'selected',
});
const target = gestureTarget();
act(() => {
result.current.onNodePointerDown(pointerEvent(target, 1, 0, 0), child);
result.current.onNodePointerMove(pointerEvent(target, 1, 4, 0));
});
expect(canvasProjection.updateNodeTransform).toHaveBeenCalledWith(
'page',
'selected',
expect.anything(),
);
});
it('defers node selection until a clean pointerup', () => {
const { result, canvasProjection } = renderInteraction();
const target = gestureTarget();
@@ -41,4 +41,4 @@ Container 专属数据是互斥 tagged unionHBox/VBox 存 `alignment + separa
布局只在 Inspector 编辑。Preview 不为 Container 管理的 child 提供拖拽或缩放手柄;Inspector 会提示其 Transform 被父 Container 忽略。普通 Control 仍保留原 Transform 编辑和自由预览操作。
Preview 的节点选择采用“松开确认”语义:节点或调整手柄按下时只建立指针手势,不立即改变选择;沿用现有 `2px` 屏幕位移阈值(达到阈值即视为拖动),未达到阈值且在预览区域内松开时才选择按下命中的节点,发生拖动、取消、失焦或在区域外松开均不选择。拖动外层节点时,嵌套 child 产生的后续 click 也不得重新选择;未发生拖动时,嵌套 child 仍按实际命中节点选择。
Preview 的节点选择采用“松开确认”语义:节点或调整手柄按下时只建立指针手势,不立即改变选择;沿用现有 `2px` 屏幕位移阈值(达到阈值即视为拖动),未达到阈值且在预览区域内松开时才选择按下命中的节点,发生拖动、取消、失焦或在区域外松开均不选择。发生拖动时,Transform 更新作用于按下前已选中的节点;没有当前选择时才回退到按下节点。拖动外层节点时,嵌套 child 产生的后续 click 也不得重新选择;未发生拖动时,嵌套 child 仍按实际命中节点选择。