76dfd74dea
PreviewWorkspace.tsx 的 onWindowBlur 一并清掉 treeDragRef 与树拖拽预览偏移:失焦时浏览器不一定补 pointercancel,陈旧手势会被后续 pointerup 复用并把意外偏移写进文档 tests/previewRightPanDrag.test.tsx 补失焦后抬起不提交偏移的用例
548 lines
15 KiB
TypeScript
548 lines
15 KiB
TypeScript
// @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);
|
||
});
|
||
});
|
||
});
|