Files
Genarrative/apps/ai-game-creator-shell/tests/previewRightPanDrag.test.tsx
k88936 76dfd74dea 窗口失焦时丢弃未提交的树拖拽
PreviewWorkspace.tsx 的 onWindowBlur 一并清掉 treeDragRef 与树拖拽预览偏移:失焦时浏览器不一定补 pointercancel,陈旧手势会被后续 pointerup 复用并把意外偏移写进文档
tests/previewRightPanDrag.test.tsx 补失焦后抬起不提交偏移的用例
2026-09-24 16:07:56 +08:00

548 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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(),
setTreeOffset: 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 tree drag', () => {
function renderTreeDrag() {
const rendered = renderPreview();
const treeLayer = rendered.container.querySelector(
'[data-tree-id]',
) as HTMLDivElement;
const rootNode = treeLayer.querySelector(
'[data-node-id="root"]',
) as HTMLDivElement;
return { ...rendered, rootNode, treeLayer };
}
it('commits the tree offset on pointerup but not on pointercancel', () => {
const committed = renderTreeDrag();
dispatchPointer(committed.rootNode, 'pointerdown', {
button: 0,
clientX: 10,
clientY: 10,
pointerId: 11,
});
// 指针捕获在树容器上,后续指针事件由浏览器派发给捕获元素。
dispatchPointer(committed.treeLayer, 'pointermove', {
clientX: 40,
clientY: 50,
pointerId: 11,
});
dispatchPointer(committed.treeLayer, 'pointerup', {
button: 0,
clientX: 40,
clientY: 50,
pointerId: 11,
});
expect(committed.canvas.setTreeOffset).toHaveBeenCalledTimes(1);
expect(committed.canvas.setTreeOffset.mock.calls[0]?.[0]).toBe('page-1');
cleanup();
const cancelled = renderTreeDrag();
dispatchPointer(cancelled.rootNode, 'pointerdown', {
button: 0,
clientX: 10,
clientY: 10,
pointerId: 12,
});
dispatchPointer(cancelled.treeLayer, 'pointermove', {
clientX: 40,
clientY: 50,
pointerId: 12,
});
dispatchPointer(cancelled.treeLayer, 'pointercancel', {
button: 0,
clientX: 40,
clientY: 50,
pointerId: 12,
});
expect(cancelled.canvas.setTreeOffset).not.toHaveBeenCalled();
});
it('drops an in-flight tree drag when the window loses focus', () => {
const { canvas, rootNode, treeLayer } = renderTreeDrag();
dispatchPointer(rootNode, 'pointerdown', {
button: 0,
clientX: 10,
clientY: 10,
pointerId: 13,
});
dispatchPointer(treeLayer, 'pointermove', {
clientX: 40,
clientY: 50,
pointerId: 13,
});
act(() => {
window.dispatchEvent(new Event('blur'));
});
dispatchPointer(treeLayer, 'pointerup', {
button: 0,
clientX: 40,
clientY: 50,
pointerId: 13,
});
expect(canvas.setTreeOffset).not.toHaveBeenCalled();
});
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('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', () => {
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('leaves the keyboard menu key to the existing node menu path', () => {
const { canvas, child } = renderPreview();
dispatchPointer(child, 'pointerdown', {
button: 2,
clientX: 100,
clientY: 100,
pointerId: 13,
});
// 键盘菜单键在 Chromium 上报 button: -1,不能被右键手势的拦截窗口吃掉。
expect(fireEvent.contextMenu(child, { button: -1 })).toBe(false);
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);
});
});
});