预览画布接入右键拖拽平移
- PreviewWorkspace 右键按下记录起手并捕获指针,越过共用阈值后按起点全量 delta 平移视口,抬起时由 resolvePreviewRightPanRelease 裁决平移还是节点菜单 - 捕获阶段拦截按钮 2 的 contextmenu,拦截窗口保留到下一次指针按下之前,兼容"按下触发"与"抬起后触发"两类平台时序 - 右键抬起用 [data-node-id] 加树容器新增的 data-tree-id 命中节点,沿用"右键即选中该节点"语义;未平移但抬起在预览外时不弹菜单,空白网格右键静默 - 视口接线共享 isPanning,让空格、中键、右键三个平移绑定共用 grabbing 光标;onPointerCancel 拆成不进菜单的独立处理 - 新增 tests/previewRightPanDrag.test.tsx:右键拖拽只平移不弹菜单、阈值内仍弹菜单、空白右键不改选择、预览外抬起不弹、非右键 contextmenu 仍即时、中键平移回归、右键平移后左键拖动仍可用 验证:npx vitest run 定向 7 个文件 59 passed、apps/ai-game-creator-shell npx tsc --noEmit、改动文件 eslint、npm run check:encoding、git diff --check
This commit is contained in:
+144
-4
@@ -34,6 +34,13 @@ import type { NodeId } from '../../../../features/ui-editor/types/NodeId';
|
||||
import type { UiEditorCanvasProjection } from '../../useUiEditorPage';
|
||||
import { UiNodeContextMenu } from '../UiNodeContextMenu';
|
||||
import { resolvePreviewGridStep } from './previewGrid';
|
||||
import {
|
||||
createPreviewRightPanGesture,
|
||||
movePreviewRightPanGesture,
|
||||
type PreviewRightPanGesture,
|
||||
resolvePreviewRightPanRelease,
|
||||
shouldInterceptRightContextMenu,
|
||||
} from './previewRightPanGesture';
|
||||
import {
|
||||
handlePreviewZoomKeyDown,
|
||||
isPreviewZoomInteractiveTarget,
|
||||
@@ -45,6 +52,40 @@ import { useNodeTransformInteraction } from './useNodeTransformInteraction';
|
||||
/** 画布初始适配比例;调小可为浮动面板预留更多可视空间。 */
|
||||
export const UI_EDITOR_FIT_DEGREE = 0.7;
|
||||
|
||||
function releasePointerCapture(element: Element, pointerId: number) {
|
||||
if (element.hasPointerCapture(pointerId)) {
|
||||
element.releasePointerCapture(pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
function isClientPointInsideElement(
|
||||
element: Element | null,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) {
|
||||
if (!element) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
clientX >= rect.left &&
|
||||
clientX <= rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY <= rect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
/** 右键抬起时命中的节点与它所属的树,用于打开节点菜单。 */
|
||||
function resolveRightClickMenuTarget(target: EventTarget | null) {
|
||||
const element = target instanceof Element ? target : null;
|
||||
const nodeId = element
|
||||
?.closest('[data-node-id]')
|
||||
?.getAttribute('data-node-id');
|
||||
const treeId = element
|
||||
?.closest('[data-tree-id]')
|
||||
?.getAttribute('data-tree-id');
|
||||
if (!nodeId || !treeId) return null;
|
||||
return { nodeId, treeId };
|
||||
}
|
||||
|
||||
function fitEditorViewport(
|
||||
bounds: { x: number; y: number; width: number; height: number },
|
||||
canvasSize: { width: number; height: number },
|
||||
@@ -75,6 +116,8 @@ export function PreviewWorkspace({
|
||||
const handledFocusRequestIdRef = useRef<number | null>(null);
|
||||
const didInitialFitRef = useRef(false);
|
||||
const panRef = useRef<Extract<DragState, { kind: 'pan' }> | null>(null);
|
||||
const rightPanRef = useRef<PreviewRightPanGesture | null>(null);
|
||||
const rightPressSequenceRef = useRef(false);
|
||||
const treeDragRef = useRef<{
|
||||
treeId: string;
|
||||
pointerId: number;
|
||||
@@ -93,6 +136,7 @@ export function PreviewWorkspace({
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 });
|
||||
const canvasSizeRef = useRef(canvasSize);
|
||||
const [spaceHeld, setSpaceHeld] = useState(false);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [previewTransforms, setPreviewTransforms] = useState<
|
||||
ReadonlyMap<NodeId, Node['layout']['transform']>
|
||||
>(new Map());
|
||||
@@ -351,7 +395,12 @@ export function PreviewWorkspace({
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.code === 'Space') setSpaceHeld(false);
|
||||
};
|
||||
const onWindowBlur = () => setSpaceHeld(false);
|
||||
const onWindowBlur = () => {
|
||||
setSpaceHeld(false);
|
||||
rightPressSequenceRef.current = false;
|
||||
rightPanRef.current = null;
|
||||
setIsPanning(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', onWindowBlur);
|
||||
@@ -363,12 +412,29 @@ export function PreviewWorkspace({
|
||||
}, [allBounds, resetToActualSize, zoomIn, zoomOut]);
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
// 右键 contextmenu 的拦截窗口要覆盖"按下到下一次按下"这段:
|
||||
// Windows / Linux 在按下时触发,macOS 在抬起之后触发。
|
||||
rightPressSequenceRef.current = event.button === 2;
|
||||
if (event.button === 2) {
|
||||
// 只接"单按右键"的干净起手,避免和左键拖动/框选互相抢指针捕获。
|
||||
if (event.buttons !== 2 || rightPanRef.current !== null) return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus({ preventScroll: true });
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
rightPanRef.current = createPreviewRightPanGesture({
|
||||
pointerId: event.pointerId,
|
||||
pointer: { x: event.clientX, y: event.clientY },
|
||||
viewport: viewportRef.current,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.button === 0 && !isPreviewZoomInteractiveTarget(event.target)) {
|
||||
event.currentTarget.focus({ preventScroll: true });
|
||||
}
|
||||
if (event.button === 1 || (event.button === 0 && spaceHeld)) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setIsPanning(true);
|
||||
panRef.current = createPanDragState({
|
||||
pointerId: event.pointerId,
|
||||
pointer: { x: event.clientX, y: event.clientY },
|
||||
@@ -382,6 +448,18 @@ export function PreviewWorkspace({
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const rightPan = rightPanRef.current;
|
||||
if (rightPan && rightPan.pointerId === event.pointerId) {
|
||||
const pointer = { x: event.clientX, y: event.clientY };
|
||||
const nextPan = movePreviewRightPanGesture(rightPan, pointer);
|
||||
if (nextPan !== rightPan) {
|
||||
rightPanRef.current = nextPan;
|
||||
setIsPanning(true);
|
||||
}
|
||||
if (nextPan.panning)
|
||||
setViewport(moveViewportFromPan(nextPan.pan, pointer));
|
||||
return;
|
||||
}
|
||||
const drag = panRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
setViewport(
|
||||
@@ -389,11 +467,59 @@ export function PreviewWorkspace({
|
||||
);
|
||||
};
|
||||
|
||||
const openNodeContextMenuAt = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const target = resolveRightClickMenuTarget(event.target);
|
||||
if (!target) return;
|
||||
const layout = treeLayouts.find(
|
||||
(item) => item.tree.src_ui_design === target.treeId,
|
||||
);
|
||||
if (!layout) return;
|
||||
canvas.selectNode(target.nodeId);
|
||||
setContextMenu({
|
||||
nodeId: target.nodeId,
|
||||
treeId: target.treeId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
isPageRoot: layout.tree.root.id === target.nodeId,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const rightPan = rightPanRef.current;
|
||||
if (rightPan && rightPan.pointerId === event.pointerId) {
|
||||
rightPanRef.current = null;
|
||||
setIsPanning(false);
|
||||
releasePointerCapture(event.currentTarget, event.pointerId);
|
||||
const release = resolvePreviewRightPanRelease({
|
||||
gesture: rightPan,
|
||||
pointer: { x: event.clientX, y: event.clientY },
|
||||
isInsidePreview: isClientPointInsideElement(
|
||||
viewportElementRef.current,
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
),
|
||||
});
|
||||
if (release.viewport) setViewport(release.viewport);
|
||||
if (release.openNodeMenu) openNodeContextMenuAt(event);
|
||||
return;
|
||||
}
|
||||
if (panRef.current?.pointerId !== event.pointerId) return;
|
||||
panRef.current = null;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
setIsPanning(false);
|
||||
releasePointerCapture(event.currentTarget, event.pointerId);
|
||||
};
|
||||
|
||||
const handlePointerCancel = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const rightPan = rightPanRef.current;
|
||||
const pan = panRef.current;
|
||||
const cancelsRightPan =
|
||||
rightPan !== null && rightPan.pointerId === event.pointerId;
|
||||
const cancelsPan = pan !== null && pan.pointerId === event.pointerId;
|
||||
if (cancelsRightPan) rightPanRef.current = null;
|
||||
if (cancelsPan) panRef.current = null;
|
||||
if (cancelsRightPan || cancelsPan) {
|
||||
setIsPanning(false);
|
||||
releasePointerCapture(event.currentTarget, event.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -490,6 +616,7 @@ export function PreviewWorkspace({
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<SharedCanvasViewport
|
||||
ref={viewportElementRef}
|
||||
isPanning={isPanning}
|
||||
className="size-full select-none outline-none bg-[radial-gradient(#b8aaa0_1.5px,transparent_1.5px)]"
|
||||
style={{
|
||||
backgroundPosition: `${viewport.x}px ${viewport.y}px`,
|
||||
@@ -518,7 +645,19 @@ export function PreviewWorkspace({
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onContextMenuCapture={(event) => {
|
||||
if (
|
||||
!shouldInterceptRightContextMenu({
|
||||
button: event.button,
|
||||
withinRightPressSequence: rightPressSequenceRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
@@ -537,6 +676,7 @@ export function PreviewWorkspace({
|
||||
return (
|
||||
<div
|
||||
key={item.src_ui_design}
|
||||
data-tree-id={item.src_ui_design}
|
||||
className="absolute"
|
||||
style={{ left: offset[0], top: offset[1], width, height }}
|
||||
onPointerDown={(event) =>
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Node as UiNode } from '../src/features/ui-editor/types/Node';
|
||||
import type { UIDesignImage } from '../src/features/ui-editor/types/UIDesignImage';
|
||||
import { PreviewWorkspace } from '../src/view/ui-editor/components/preview/PreviewWorkspace';
|
||||
import type { UiEditorCanvasProjection } from '../src/view/ui-editor/useUiEditorPage';
|
||||
|
||||
const PANNING_CLASS = 'genarrative-image-canvas__viewport--panning';
|
||||
const DEFAULT_BUTTONS: Record<number, number> = { 0: 1, 1: 4, 2: 2 };
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
offset: { min: [number, number]; max: [number, number] },
|
||||
children: UiNode[] = [],
|
||||
): UiNode {
|
||||
return {
|
||||
id,
|
||||
layout: {
|
||||
transform: {
|
||||
anchor_min: [0, 0],
|
||||
anchor_max: [1, 1],
|
||||
offset_min: [0, 0],
|
||||
offset_max: [0, 0],
|
||||
},
|
||||
custom_minimum_size: [0, 0],
|
||||
size_flags_horizontal: 1,
|
||||
size_flags_vertical: 1,
|
||||
size_flags_stretch_ratio: 1,
|
||||
container: 'None',
|
||||
},
|
||||
metadata: {
|
||||
name: id,
|
||||
description: '',
|
||||
layout_status: 'NoProblem',
|
||||
component_status: 'NoProblem',
|
||||
allow_llm_edit_layout: true,
|
||||
allow_llm_edit_component: true,
|
||||
source: 'System',
|
||||
},
|
||||
component: null,
|
||||
children_display_mode: 'Stack',
|
||||
children,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
const activeImage: UIDesignImage = {
|
||||
metadata: {
|
||||
name: '测试界面',
|
||||
description: '',
|
||||
role: null,
|
||||
slave_to: null,
|
||||
},
|
||||
path: 'assets/page.png',
|
||||
pixel_size: [1200, 800],
|
||||
pixels_per_unit: 1,
|
||||
};
|
||||
|
||||
const root = node('root', { min: [0, 0], max: [1200, 800] }, [
|
||||
node('child', { min: [40, 40], max: [440, 240] }),
|
||||
]);
|
||||
|
||||
const PREVIEW_RECT = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 800,
|
||||
bottom: 600,
|
||||
width: 800,
|
||||
height: 600,
|
||||
toJSON: () => PREVIEW_RECT,
|
||||
} as DOMRect;
|
||||
|
||||
let previewRect = PREVIEW_RECT;
|
||||
|
||||
function createCanvas(
|
||||
overrides: Partial<UiEditorCanvasProjection> = {},
|
||||
): UiEditorCanvasProjection {
|
||||
return {
|
||||
isLocked: false,
|
||||
activeImage,
|
||||
activeImageId: 'page-1',
|
||||
uiTrees: [{ src_ui_design: 'page-1', root }],
|
||||
previewUrls: { 'page-1': 'data:image/png;base64,' },
|
||||
images: { 'page-1': activeImage },
|
||||
sprites: {},
|
||||
fontFaces: {},
|
||||
tree: { src_ui_design: 'page-1', root },
|
||||
selectedNode: null,
|
||||
selectedNodeId: null,
|
||||
keepChildrenUnchanged: false,
|
||||
hiddenNodeIds: new Set(),
|
||||
focusRequest: null,
|
||||
status: null,
|
||||
isNodePreviewVisible: vi.fn(() => true),
|
||||
toggleNodePreviewVisibility: vi.fn(),
|
||||
selectExclusiveChild: vi.fn(),
|
||||
selectNode: vi.fn(),
|
||||
clearNodeSelection: vi.fn(),
|
||||
updateNodeTransform: vi.fn(),
|
||||
insertNode: vi.fn(),
|
||||
insertNodeAfter: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
openClearDialog: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
const canvas = createCanvas();
|
||||
const rendered = render(
|
||||
<PreviewWorkspace
|
||||
canvas={canvas}
|
||||
showFrame={false}
|
||||
showOriginImage={false}
|
||||
showComponent={true}
|
||||
/>,
|
||||
);
|
||||
const preview = screen.getByRole('region', { name: 'UI 预览画布' });
|
||||
const child = rendered.container.querySelector(
|
||||
'[data-node-id="child"]',
|
||||
) as HTMLDivElement;
|
||||
return { canvas, child, preview, ...rendered };
|
||||
}
|
||||
|
||||
/** 世界图层的位移,用来断言一个手势到底有没有平移视口。 */
|
||||
function worldTranslate(container: HTMLElement) {
|
||||
const world = container.querySelector(
|
||||
'.genarrative-image-canvas__world',
|
||||
) as HTMLElement;
|
||||
const match = /translate\(([-\d.]+)px, ([-\d.]+)px\)/.exec(
|
||||
world.style.transform,
|
||||
);
|
||||
if (!match) throw new Error('无法从 world 读取视口位移');
|
||||
return { x: Number(match[1]), y: Number(match[2]) };
|
||||
}
|
||||
|
||||
/**
|
||||
* jsdom 没有 PointerEvent,`fireEvent` 也不会带 pointerId;
|
||||
* 这里手工派发带 pointerId 的指针事件,让"指针 id 不匹配"这类分支被真实覆盖。
|
||||
*/
|
||||
function dispatchPointer(
|
||||
target: Element,
|
||||
type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel',
|
||||
{
|
||||
button = 0,
|
||||
buttons,
|
||||
clientX,
|
||||
clientY,
|
||||
pointerId,
|
||||
}: {
|
||||
button?: number;
|
||||
buttons?: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
pointerId: number;
|
||||
},
|
||||
) {
|
||||
const event = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button,
|
||||
buttons: buttons ?? DEFAULT_BUTTONS[button] ?? 1,
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
Object.defineProperty(event, 'pointerId', { value: pointerId });
|
||||
act(() => {
|
||||
target.dispatchEvent(event);
|
||||
});
|
||||
return event.defaultPrevented;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
previewRect = PREVIEW_RECT;
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver);
|
||||
Object.assign(Element.prototype, {
|
||||
setPointerCapture: vi.fn(),
|
||||
hasPointerCapture: vi.fn(() => true),
|
||||
releasePointerCapture: vi.fn(),
|
||||
});
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(
|
||||
() => previewRect,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('PreviewWorkspace right-button pan', () => {
|
||||
it('pans the viewport on a right drag and never opens the node menu', () => {
|
||||
const { canvas, child, container, preview } = renderPreview();
|
||||
const before = worldTranslate(container);
|
||||
|
||||
dispatchPointer(child, 'pointerdown', {
|
||||
button: 2,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
pointerId: 5,
|
||||
});
|
||||
expect(preview.classList.contains(PANNING_CLASS)).toBe(false);
|
||||
expect(
|
||||
fireEvent.contextMenu(child, {
|
||||
button: 2,
|
||||
buttons: 2,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
dispatchPointer(child, 'pointermove', {
|
||||
clientX: 101,
|
||||
clientY: 100,
|
||||
pointerId: 5,
|
||||
});
|
||||
expect(preview.classList.contains(PANNING_CLASS)).toBe(false);
|
||||
expect(worldTranslate(container)).toEqual(before);
|
||||
|
||||
dispatchPointer(child, 'pointermove', {
|
||||
clientX: 500,
|
||||
clientY: 500,
|
||||
pointerId: 99,
|
||||
});
|
||||
expect(worldTranslate(container)).toEqual(before);
|
||||
|
||||
dispatchPointer(child, 'pointermove', {
|
||||
clientX: 130,
|
||||
clientY: 140,
|
||||
pointerId: 5,
|
||||
});
|
||||
expect(preview.classList.contains(PANNING_CLASS)).toBe(true);
|
||||
expect(worldTranslate(container)).toEqual({
|
||||
x: before.x + 30,
|
||||
y: before.y + 40,
|
||||
});
|
||||
|
||||
dispatchPointer(child, 'pointerup', {
|
||||
button: 2,
|
||||
clientX: 130,
|
||||
clientY: 140,
|
||||
pointerId: 5,
|
||||
});
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
expect(canvas.selectNode).not.toHaveBeenCalled();
|
||||
expect(canvas.clearNodeSelection).not.toHaveBeenCalled();
|
||||
expect(preview.classList.contains(PANNING_CLASS)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a right press under the drag threshold as a node menu click', () => {
|
||||
const { canvas, child, preview } = renderPreview();
|
||||
|
||||
dispatchPointer(child, 'pointerdown', {
|
||||
button: 2,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
pointerId: 6,
|
||||
});
|
||||
dispatchPointer(child, 'pointermove', {
|
||||
clientX: 101,
|
||||
clientY: 100,
|
||||
pointerId: 6,
|
||||
});
|
||||
expect(preview.classList.contains(PANNING_CLASS)).toBe(false);
|
||||
dispatchPointer(child, 'pointerup', {
|
||||
button: 2,
|
||||
clientX: 101,
|
||||
clientY: 100,
|
||||
pointerId: 6,
|
||||
});
|
||||
|
||||
expect(canvas.selectNode).toHaveBeenCalledWith('child');
|
||||
expect(screen.getByRole('menu').style.left).toBe('101px');
|
||||
expect(screen.getByRole('menuitem', { name: '新增同级节点' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps an empty-grid right click silent, unlike the left button', () => {
|
||||
const { canvas, preview } = renderPreview();
|
||||
|
||||
dispatchPointer(preview, 'pointerdown', {
|
||||
button: 2,
|
||||
clientX: 300,
|
||||
clientY: 200,
|
||||
pointerId: 7,
|
||||
});
|
||||
dispatchPointer(preview, 'pointerup', {
|
||||
button: 2,
|
||||
clientX: 300,
|
||||
clientY: 200,
|
||||
pointerId: 7,
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
expect(canvas.selectNode).not.toHaveBeenCalled();
|
||||
expect(canvas.clearNodeSelection).not.toHaveBeenCalled();
|
||||
|
||||
dispatchPointer(preview, 'pointerdown', {
|
||||
clientX: 300,
|
||||
clientY: 200,
|
||||
pointerId: 8,
|
||||
});
|
||||
expect(canvas.clearNodeSelection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not open the node menu when a clean right press ends outside the preview', () => {
|
||||
const { canvas, child } = renderPreview();
|
||||
previewRect = { ...PREVIEW_RECT, right: 200, bottom: 200 } as DOMRect;
|
||||
|
||||
dispatchPointer(child, 'pointerdown', {
|
||||
button: 2,
|
||||
clientX: 300,
|
||||
clientY: 200,
|
||||
pointerId: 9,
|
||||
});
|
||||
dispatchPointer(child, 'pointerup', {
|
||||
button: 2,
|
||||
clientX: 300,
|
||||
clientY: 200,
|
||||
pointerId: 9,
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
expect(canvas.selectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the non-right-button context menu path immediate', () => {
|
||||
const { canvas, child } = renderPreview();
|
||||
|
||||
fireEvent.contextMenu(child, { button: 0, clientX: 40, clientY: 50 });
|
||||
|
||||
expect(canvas.selectNode).toHaveBeenCalledWith('child');
|
||||
expect(screen.getByRole('menu')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps middle-button panning and its shared grabbing cursor', () => {
|
||||
const { container, preview } = renderPreview();
|
||||
const before = worldTranslate(container);
|
||||
|
||||
dispatchPointer(preview, 'pointerdown', {
|
||||
button: 1,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
pointerId: 10,
|
||||
});
|
||||
expect(preview.classList.contains(PANNING_CLASS)).toBe(true);
|
||||
dispatchPointer(preview, 'pointermove', {
|
||||
clientX: 120,
|
||||
clientY: 90,
|
||||
pointerId: 10,
|
||||
});
|
||||
expect(worldTranslate(container)).toEqual({
|
||||
x: before.x + 20,
|
||||
y: before.y - 10,
|
||||
});
|
||||
dispatchPointer(preview, 'pointerup', {
|
||||
button: 1,
|
||||
clientX: 120,
|
||||
clientY: 90,
|
||||
pointerId: 10,
|
||||
});
|
||||
expect(preview.classList.contains(PANNING_CLASS)).toBe(false);
|
||||
});
|
||||
|
||||
it('still drags nodes with the left button after a right-drag pan', () => {
|
||||
const { canvas, child } = renderPreview();
|
||||
|
||||
dispatchPointer(child, 'pointerdown', {
|
||||
button: 2,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
pointerId: 11,
|
||||
});
|
||||
dispatchPointer(child, 'pointermove', {
|
||||
clientX: 140,
|
||||
clientY: 100,
|
||||
pointerId: 11,
|
||||
});
|
||||
dispatchPointer(child, 'pointerup', {
|
||||
button: 2,
|
||||
clientX: 140,
|
||||
clientY: 100,
|
||||
pointerId: 11,
|
||||
});
|
||||
expect(canvas.updateNodeTransform).not.toHaveBeenCalled();
|
||||
|
||||
dispatchPointer(child, 'pointerdown', {
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
pointerId: 12,
|
||||
});
|
||||
dispatchPointer(child, 'pointermove', {
|
||||
clientX: 110,
|
||||
clientY: 100,
|
||||
pointerId: 12,
|
||||
});
|
||||
dispatchPointer(child, 'pointerup', {
|
||||
clientX: 110,
|
||||
clientY: 100,
|
||||
pointerId: 12,
|
||||
});
|
||||
|
||||
expect(canvas.updateNodeTransform).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user