修复预览节点松开选择手势

按下阶段仅记录节点手势与命中目标

在干净松开时选择并处理拖动取消失焦边界

阻止拖动后的嵌套 click 重新选择
This commit is contained in:
2026-09-03 13:37:27 +08:00
parent 8ed0626627
commit fcacdca4a4
3 changed files with 103 additions and 12 deletions
@@ -87,15 +87,19 @@ export function PreviewWorkspace({
logicalSize,
spaceHeld,
tree,
previewRef: viewportElementRef,
viewportRef,
});
const {
onNodePointerDown,
onNodePointerMove,
onNodePointerUp,
onNodePointerCancel,
onNodeResizePointerDown,
onNodeResizePointerMove,
onNodeResizePointerUp,
onNodeResizePointerCancel,
consumeNodeClick,
} = nodeInteractions;
const setViewport = useCallback((next: CanvasViewport) => {
@@ -389,13 +393,16 @@ export function PreviewWorkspace({
fontFaces: canvas.fontFaces,
}}
onSelectNode={canvas.selectNode}
consumeNodeClick={consumeNodeClick}
onNodeContextMenu={handleNodeContextMenu}
onNodePointerDown={onNodePointerDown}
onNodePointerMove={onNodePointerMove}
onNodePointerUp={onNodePointerUp}
onNodePointerCancel={onNodePointerCancel}
onNodeResizePointerDown={onNodeResizePointerDown}
onNodeResizePointerMove={onNodeResizePointerMove}
onNodeResizePointerUp={onNodeResizePointerUp}
onNodeResizePointerCancel={onNodeResizePointerCancel}
onSelectExclusiveChild={canvas.selectExclusiveChild}
viewportScale={viewport.scale}
/>
@@ -35,6 +35,7 @@ type UiTreeRendererProps = {
selectedNodeId: NodeId | null;
resources: PreviewComponentResources;
onSelectNode: (id: NodeId) => void;
consumeNodeClick?: () => boolean;
onNodeContextMenu: (
event: ReactMouseEvent<HTMLDivElement>,
node: UiNode,
@@ -43,6 +44,7 @@ type UiTreeRendererProps = {
onNodePointerDown: NodePointerDown;
onNodePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodePointerCancel?: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodeResizePointerDown: (
event: ReactPointerEvent<HTMLDivElement>,
node: UiNode,
@@ -50,6 +52,9 @@ type UiTreeRendererProps = {
) => void;
onNodeResizePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodeResizePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodeResizePointerCancel?: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
onSelectExclusiveChild: (nodeId: NodeId) => void;
viewportScale: number;
};
@@ -80,13 +85,16 @@ function RenderNode({
selectedNodeId,
resources,
onSelectNode,
consumeNodeClick = () => false,
onNodeContextMenu,
onNodePointerDown,
onNodePointerMove,
onNodePointerUp,
onNodePointerCancel = onNodePointerUp,
onNodeResizePointerDown,
onNodeResizePointerMove,
onNodeResizePointerUp,
onNodeResizePointerCancel = onNodeResizePointerUp,
onSelectExclusiveChild,
viewportScale,
}: Omit<UiTreeRendererProps, 'tree'> & {
@@ -109,6 +117,7 @@ function RenderNode({
const isEditorOverlay = renderMode === 'editor-overlay';
const isFrameVisible = isEditorOverlay || showFrame;
const hasDirectPointerGesture = !isRoot && !parentContainer;
const exclusiveVisibleChildId =
node.children_display_mode === 'Exclusive'
? resolveExclusiveVisibleChildId(node.children, hiddenNodeIds)
@@ -137,10 +146,18 @@ function RenderNode({
}
: {}),
}}
onClick={(event) => {
event.stopPropagation();
onSelectNode(node.id);
}}
onClick={
hasDirectPointerGesture
? (event) => {
event.stopPropagation();
consumeNodeClick();
}
: (event) => {
event.stopPropagation();
if (consumeNodeClick()) return;
onSelectNode(node.id);
}
}
onContextMenu={(event) => {
if (!isEditorOverlay) return;
event.preventDefault();
@@ -153,7 +170,7 @@ function RenderNode({
}
onPointerMove={parentContainer ? undefined : onNodePointerMove}
onPointerUp={parentContainer ? undefined : onNodePointerUp}
onPointerCancel={parentContainer ? undefined : onNodePointerUp}
onPointerCancel={parentContainer ? undefined : onNodePointerCancel}
title={isFrameVisible ? node.metadata.name || undefined : undefined}
>
{isFrameVisible && node.metadata.name ? (
@@ -192,13 +209,16 @@ function RenderNode({
selectedNodeId={selectedNodeId}
resources={resources}
onSelectNode={onSelectNode}
consumeNodeClick={consumeNodeClick}
onNodeContextMenu={onNodeContextMenu}
onNodePointerDown={onNodePointerDown}
onNodePointerMove={onNodePointerMove}
onNodePointerUp={onNodePointerUp}
onNodePointerCancel={onNodePointerCancel}
onNodeResizePointerDown={onNodeResizePointerDown}
onNodeResizePointerMove={onNodeResizePointerMove}
onNodeResizePointerUp={onNodeResizePointerUp}
onNodeResizePointerCancel={onNodeResizePointerCancel}
onSelectExclusiveChild={onSelectExclusiveChild}
viewportScale={viewportScale}
/>
@@ -228,7 +248,7 @@ function RenderNode({
}
onPointerMove={onNodeResizePointerMove}
onPointerUp={onNodeResizePointerUp}
onPointerCancel={onNodeResizePointerUp}
onPointerCancel={onNodeResizePointerCancel}
/>
))
: null}
@@ -28,6 +28,7 @@ type GestureBase = {
startClientX: number;
startClientY: number;
startTransform: UiNode['layout']['transform'];
hitNodeId: string;
hasMoved: boolean;
};
@@ -78,6 +79,32 @@ function releasePointer(target: HTMLDivElement, pointerId: number) {
}
}
function resolveHitNodeId(
event: ReactPointerEvent<HTMLDivElement>,
fallback: string,
) {
const target = event.target;
if (!(target instanceof Element)) return fallback;
return (
target.closest<HTMLElement>('[data-node-id]')?.dataset.nodeId ?? fallback
);
}
function isInsidePreview(
event: ReactPointerEvent<HTMLDivElement>,
previewRef: RefObject<HTMLElement | null> | undefined,
) {
const preview = previewRef?.current;
if (!preview) return true;
const rect = preview.getBoundingClientRect();
return (
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
);
}
export function useNodeTransformInteraction({
activeImageId,
canvas,
@@ -85,6 +112,7 @@ export function useNodeTransformInteraction({
spaceHeld,
tree,
viewportRef,
previewRef,
}: {
activeImageId: UiEditorCanvasProjection['activeImageId'];
canvas: Pick<UiEditorCanvasProjection, 'selectNode' | 'updateNodeTransform'>;
@@ -92,8 +120,10 @@ export function useNodeTransformInteraction({
spaceHeld: boolean;
tree: UITree | null;
viewportRef: RefObject<ViewportScale>;
previewRef?: RefObject<HTMLElement | null>;
}) {
const activeGestureRef = useRef<ActiveGesture | null>(null);
const suppressNextNodeClickRef = useRef(false);
const cancelGesture = useCallback(() => {
const gesture = activeGestureRef.current;
@@ -103,6 +133,12 @@ export function useNodeTransformInteraction({
useEffect(() => cancelGesture, [cancelGesture]);
useEffect(() => {
const cancelOnWindowBlur = () => cancelGesture();
window.addEventListener('blur', cancelOnWindowBlur);
return () => window.removeEventListener('blur', cancelOnWindowBlur);
}, [cancelGesture]);
useEffect(() => {
const gesture = activeGestureRef.current;
if (
@@ -140,7 +176,7 @@ export function useNodeTransformInteraction({
}
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
canvas.selectNode(node.id);
suppressNextNodeClickRef.current = false;
activeGestureRef.current = {
kind: 'drag',
treeId: activeImageId,
@@ -150,10 +186,11 @@ export function useNodeTransformInteraction({
startClientX: event.clientX,
startClientY: event.clientY,
startTransform: structuredClone(node.layout.transform),
hitNodeId: resolveHitNodeId(event, node.id),
hasMoved: false,
};
},
[activeImageId, canvas, spaceHeld, tree?.root.id],
[activeImageId, spaceHeld, tree?.root.id],
);
const onNodePointerMove = useCallback(
@@ -164,6 +201,8 @@ export function useNodeTransformInteraction({
}
event.stopPropagation();
if (!gesture.hasMoved && !passedDragThreshold(gesture, event)) return;
gesture.hasMoved = true;
suppressNextNodeClickRef.current = true;
const scale = viewportRef.current?.scale;
if (!Number.isFinite(scale) || scale <= 0) {
cancelGesture();
@@ -175,7 +214,6 @@ export function useNodeTransformInteraction({
cancelGesture();
return;
}
gesture.hasMoved = true;
const nextTransform = structuredClone(gesture.startTransform);
nextTransform.offset_min[0] += logicalDeltaX;
nextTransform.offset_min[1] += logicalDeltaY;
@@ -191,6 +229,21 @@ export function useNodeTransformInteraction({
);
const onNodePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const gesture = activeGestureRef.current;
if (!gesture || !acceptsGestureEvent(event)) return;
event.stopPropagation();
if (gesture.hasMoved) suppressNextNodeClickRef.current = true;
if (!gesture.hasMoved && isInsidePreview(event, previewRef)) {
canvas.selectNode(gesture.hitNodeId);
suppressNextNodeClickRef.current = true;
}
cancelGesture();
},
[acceptsGestureEvent, cancelGesture, canvas, previewRef],
);
const onNodePointerCancel = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!acceptsGestureEvent(event)) return;
event.stopPropagation();
@@ -236,7 +289,7 @@ export function useNodeTransformInteraction({
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
canvas.selectNode(node.id);
suppressNextNodeClickRef.current = false;
activeGestureRef.current = {
kind: 'resize',
treeId: activeImageId,
@@ -247,13 +300,14 @@ export function useNodeTransformInteraction({
startClientX: event.clientX,
startClientY: event.clientY,
startTransform: structuredClone(node.layout.transform),
hitNodeId: node.id,
startRect: context.rect,
parentRect: context.parentRect,
ratioAxis: null,
hasMoved: false,
};
},
[activeImageId, canvas, logicalSize, spaceHeld, tree],
[activeImageId, logicalSize, spaceHeld, tree],
);
const onNodeResizePointerMove = useCallback(
@@ -268,6 +322,8 @@ export function useNodeTransformInteraction({
}
event.stopPropagation();
if (!gesture.hasMoved && !passedDragThreshold(gesture, event)) return;
gesture.hasMoved = true;
suppressNextNodeClickRef.current = true;
const scale = viewportRef.current?.scale;
if (!Number.isFinite(scale) || scale <= 0) {
cancelGesture();
@@ -309,18 +365,26 @@ export function useNodeTransformInteraction({
cancelGesture();
return;
}
gesture.hasMoved = true;
canvas.updateNodeTransform(gesture.treeId, gesture.nodeId, nextTransform);
},
[acceptsGestureEvent, cancelGesture, canvas, viewportRef],
);
const consumeNodeClick = useCallback(() => {
if (!suppressNextNodeClickRef.current) return false;
suppressNextNodeClickRef.current = false;
return true;
}, []);
return {
onNodePointerDown,
onNodePointerMove,
onNodePointerUp,
onNodePointerCancel,
onNodeResizePointerDown,
onNodeResizePointerMove,
onNodeResizePointerUp: onNodePointerUp,
onNodeResizePointerCancel: onNodePointerCancel,
consumeNodeClick,
};
}