预览画布接入右键拖拽平移

- 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:
2026-09-22 15:32:19 +08:00
parent f525b6dc70
commit 0ab04d0f64
2 changed files with 566 additions and 4 deletions
@@ -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);
});
});