按右键按下时的命中目标决定是否弹节点菜单

PreviewWorkspace.tsx 在右键按下时记住命中元素,抬起时改成按它解析节点与树:指针被视口捕获后 pointerup 的 target 是捕获元素,原先按抬起目标找不到 [data-node-id],节点菜单永远打不开
指针取消时同步清掉记录的目标
tests/previewRightPanDrag.test.tsx 补「抬起被重定向到捕获元素仍弹菜单」用例
This commit is contained in:
2026-09-24 16:05:16 +08:00
parent ef4601c555
commit 064c26d7c9
2 changed files with 38 additions and 4 deletions
@@ -118,6 +118,9 @@ export function PreviewWorkspace({
const didInitialFitRef = useRef(false); const didInitialFitRef = useRef(false);
const panRef = useRef<Extract<DragState, { kind: 'pan' }> | null>(null); const panRef = useRef<Extract<DragState, { kind: 'pan' }> | null>(null);
const rightPanRef = useRef<PreviewRightPanGesture | null>(null); const rightPanRef = useRef<PreviewRightPanGesture | null>(null);
// 右键按下时的命中元素:指针被视口捕获后,pointerup 的 target 会变成捕获元素,
// 必须在按下时把真正的节点/树目标记下来。
const rightPanTargetRef = useRef<EventTarget | null>(null);
const rightPressSequenceRef = useRef(false); const rightPressSequenceRef = useRef(false);
const treeDragRef = useRef<{ const treeDragRef = useRef<{
treeId: string; treeId: string;
@@ -422,6 +425,7 @@ export function PreviewWorkspace({
event.preventDefault(); event.preventDefault();
event.currentTarget.focus({ preventScroll: true }); event.currentTarget.focus({ preventScroll: true });
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
rightPanTargetRef.current = event.target;
rightPanRef.current = createPreviewRightPanGesture({ rightPanRef.current = createPreviewRightPanGesture({
pointerId: event.pointerId, pointerId: event.pointerId,
pointer: { x: event.clientX, y: event.clientY }, pointer: { x: event.clientX, y: event.clientY },
@@ -468,8 +472,11 @@ export function PreviewWorkspace({
); );
}; };
const openNodeContextMenuAt = (event: ReactPointerEvent<HTMLDivElement>) => { const openNodeContextMenuAt = (
const target = resolveRightClickMenuTarget(event.target); event: ReactPointerEvent<HTMLDivElement>,
pressedTarget: EventTarget | null,
) => {
const target = resolveRightClickMenuTarget(pressedTarget);
if (!target) return; if (!target) return;
const layout = treeLayouts.find( const layout = treeLayouts.find(
(item) => item.tree.src_ui_design === target.treeId, (item) => item.tree.src_ui_design === target.treeId,
@@ -501,7 +508,9 @@ export function PreviewWorkspace({
), ),
}); });
if (release.viewport) setViewport(release.viewport); if (release.viewport) setViewport(release.viewport);
if (release.openNodeMenu) openNodeContextMenuAt(event); const pressedTarget = rightPanTargetRef.current;
rightPanTargetRef.current = null;
if (release.openNodeMenu) openNodeContextMenuAt(event, pressedTarget);
return; return;
} }
if (panRef.current?.pointerId !== event.pointerId) return; if (panRef.current?.pointerId !== event.pointerId) return;
@@ -516,7 +525,10 @@ export function PreviewWorkspace({
const cancelsRightPan = const cancelsRightPan =
rightPan !== null && rightPan.pointerId === event.pointerId; rightPan !== null && rightPan.pointerId === event.pointerId;
const cancelsPan = pan !== null && pan.pointerId === event.pointerId; const cancelsPan = pan !== null && pan.pointerId === event.pointerId;
if (cancelsRightPan) rightPanRef.current = null; if (cancelsRightPan) {
rightPanRef.current = null;
rightPanTargetRef.current = null;
}
if (cancelsPan) panRef.current = null; if (cancelsPan) panRef.current = null;
if (cancelsRightPan || cancelsPan) { if (cancelsRightPan || cancelsPan) {
setIsPanning(false); setIsPanning(false);
@@ -292,6 +292,28 @@ describe('PreviewWorkspace right-button pan', () => {
expect(screen.getByRole('menuitem', { name: '新增同级节点' })).toBeTruthy(); expect(screen.getByRole('menuitem', { name: '新增同级节点' })).toBeTruthy();
}); });
it('opens the node menu when pointerup is retargeted to the capture element', () => {
const { canvas, child, preview } = renderPreview();
dispatchPointer(child, 'pointerdown', {
button: 2,
clientX: 100,
clientY: 100,
pointerId: 7,
});
// 指针被视口捕获后,浏览器把后续指针事件派发给捕获元素:抬起目标不再是节点,
// 菜单必须按按下时的命中目标裁决。
dispatchPointer(preview, 'pointerup', {
button: 2,
clientX: 101,
clientY: 100,
pointerId: 7,
});
expect(canvas.selectNode).toHaveBeenCalledWith('child');
expect(screen.getByRole('menu')).toBeTruthy();
});
it('keeps an empty-grid right click silent, unlike the left button', () => { it('keeps an empty-grid right click silent, unlike the left button', () => {
const { canvas, preview } = renderPreview(); const { canvas, preview } = renderPreview();