调整画布拖拽交互仅保留鼠标中键 #88

Merged
kdletters merged 7 commits from fix/middle-button-canvas-pan into master 2026-07-20 14:49:00 +08:00
4 changed files with 333 additions and 37 deletions
@@ -345,9 +345,9 @@ describe('ImageCanvasStageInteractionModel', () => {
minimapScale: 0.4,
moved: false,
});
expect(
updateMinimapDragMovement(minimapDrag, { x: 121, y: 90 }),
).toBe(minimapDrag);
expect(updateMinimapDragMovement(minimapDrag, { x: 121, y: 90 })).toBe(
minimapDrag,
);
expect(updateMinimapDragMovement(minimapDrag, { x: 123, y: 90 })).toEqual({
...minimapDrag,
moved: true,
@@ -385,7 +385,10 @@ describe('ImageCanvasStageInteractionModel', () => {
dialog: anotherDialog,
layers,
generationDialogs: [dialog, anotherDialog],
selectedLayerIds: ['layer-a', getCanvasGenerationSelectionId('dialog-1')],
selectedLayerIds: [
'layer-a',
getCanvasGenerationSelectionId('dialog-1'),
],
isMultiSelectGesture: true,
pointerId: 12,
pointer: { x: 360, y: 260 },
@@ -30,10 +30,13 @@ type PointerSource = {
};
};
type CanvasRectLike = {
left?: number;
top?: number;
} | null | undefined;
type CanvasRectLike =
| {
left?: number;
top?: number;
}
| null
| undefined;
const CANVAS_GENERATION_DIALOG_MODES = new Set([
'generate',
@@ -64,12 +67,22 @@ function hasCanvasGenerationDialogMode(
export function getPointerButton(event: PointerSource) {
const nativeButtons = Number(event.nativeEvent?.buttons);
if (Number.isFinite(nativeButtons) && (nativeButtons & 4) === 4) {
return 1;
if (Number.isFinite(nativeButtons)) {
if ((nativeButtons & 2) === 2) {
return 2;
}
if ((nativeButtons & 4) === 4) {
return 1;
}
}
const syntheticButtons = Number(event.buttons);
if (Number.isFinite(syntheticButtons) && (syntheticButtons & 4) === 4) {
return 1;
if (Number.isFinite(syntheticButtons)) {
if ((syntheticButtons & 2) === 2) {
return 2;
}
if ((syntheticButtons & 4) === 4) {
return 1;
}
}
const syntheticButton = Number(event.button);
if (Number.isFinite(syntheticButton)) {
@@ -200,9 +213,8 @@ export function createLayerDragStart({
isMultiSelectGesture,
});
const selectedCanvasLayerIds = getSelectedLayerIds(nextSelectedLayerIds);
const selectedGenerationDialogIds = getSelectedGenerationDialogIds(
nextSelectedLayerIds,
);
const selectedGenerationDialogIds =
getSelectedGenerationDialogIds(nextSelectedLayerIds);
const dragLayerIds = selectedCanvasLayerIds.includes(layer.id)
? selectedCanvasLayerIds
: [layer.id];
@@ -300,9 +312,8 @@ export function createGenerationFrameSelectionStart({
selectedIds: selectedLayerIds,
isMultiSelectGesture,
});
const selectedGenerationDialogIds = getSelectedGenerationDialogIds(
nextSelectedLayerIds,
);
const selectedGenerationDialogIds =
getSelectedGenerationDialogIds(nextSelectedLayerIds);
const selectedCanvasLayerIds = getSelectedLayerIds(nextSelectedLayerIds);
const dragDialogIds = selectedGenerationDialogIds.includes(dialog.id)
? selectedGenerationDialogIds
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { act, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import {
type PointerEvent as ReactPointerEvent,
useRef,
@@ -309,7 +309,10 @@ function StageInteractionsHarness({
</span>
<span data-testid="layers">
{layers
.map((layer) => `${layer.id}:${layer.x.toFixed(1)},${layer.y.toFixed(1)}`)
.map(
(layer) =>
`${layer.id}:${layer.x.toFixed(1)},${layer.y.toFixed(1)}`,
)
.join('|')}
</span>
<span data-testid="viewport-state">
@@ -323,7 +326,9 @@ function StageInteractionsHarness({
<span data-testid="panning">{String(interaction.isPanning)}</span>
<span data-testid="tool">{interaction.effectiveTool}</span>
<span data-testid="snap">
{interaction.snapGuide?.vertical ?? interaction.snapGuide?.horizontal ?? '-'}
{interaction.snapGuide?.vertical ??
interaction.snapGuide?.horizontal ??
'-'}
</span>
<span data-testid="dialog-position">
{generateDialog?.placeholder
@@ -337,9 +342,7 @@ function StageInteractionsHarness({
{String(generateDialog?.composerOpen ?? false)}
</span>
<span data-testid="clear-count">{clearCount}</span>
<span data-testid="image-menu-close-count">
{imageMenuCloseCount}
</span>
<span data-testid="image-menu-close-count">{imageMenuCloseCount}</span>
<button
type="button"
onClick={() => {
@@ -688,6 +691,86 @@ function StageInteractionsHarness({
>
</button>
<button
type="button"
onClick={(event) => {
interaction.handleLayerPointerDown(
createPointerEvent(event.currentTarget, {
pointerId: 20,
clientX: 40,
clientY: 40,
button: 2,
buttons: 2,
}),
firstLayer,
);
}}
>
</button>
<button
type="button"
onClick={(event) => {
setActiveTool('hand');
interaction.handleLayerPointerDown(
createPointerEvent(event.currentTarget, {
pointerId: 21,
clientX: 40,
clientY: 40,
button: 2,
buttons: 2,
}),
firstLayer,
);
}}
>
</button>
<button
type="button"
onClick={(event) => {
const frameElement = document.createElement('div');
const dialog = asCanvasGenerationDialog(generateDialog);
if (!dialog) {
return;
}
interaction.handleGenerationFramePointerDown(
createPointerEvent(frameElement, {
pointerId: 22,
clientX: 300,
clientY: 200,
button: 2,
buttons: 2,
}),
dialog,
);
}}
>
</button>
<button
type="button"
onClick={(event) => {
setActiveTool('hand');
const frameElement = document.createElement('div');
const dialog = asCanvasGenerationDialog(generateDialog);
if (!dialog) {
return;
}
interaction.handleGenerationFramePointerDown(
createPointerEvent(frameElement, {
pointerId: 23,
clientX: 300,
clientY: 200,
button: 2,
buttons: 2,
}),
dialog,
);
}}
>
</button>
</div>
);
}
@@ -778,7 +861,9 @@ describe('useImageCanvasStageInteractions', () => {
act(() => {
screen.getByRole('button', { name: '直接移动平移' }).click();
});
expect(screen.getByTestId('viewport-state').textContent).toBe('30.0,25.0,1');
expect(screen.getByTestId('viewport-state').textContent).toBe(
'30.0,25.0,1',
);
act(() => {
screen.getByRole('button', { name: '清理交互' }).click();
});
@@ -800,9 +885,7 @@ describe('useImageCanvasStageInteractions', () => {
render(<StageInteractionsHarness />);
act(() => {
screen
.getByRole('button', { name: '直接贴近图层拖生成占位' })
.click();
screen.getByRole('button', { name: '直接贴近图层拖生成占位' }).click();
});
expect(screen.getByTestId('dialog-position').textContent).toBe(
@@ -844,8 +927,9 @@ describe('useImageCanvasStageInteractions', () => {
act(() => {
screen.getByRole('button', { name: '直接追加生成器' }).click();
});
const generationSelectionId =
screen.getByTestId('dialog-selection-id').textContent;
const generationSelectionId = screen.getByTestId(
'dialog-selection-id',
).textContent;
expect(screen.getByTestId('selection').textContent).toBe(
`first:first,${generationSelectionId}`,
);
@@ -920,4 +1004,172 @@ describe('useImageCanvasStageInteractions', () => {
expect(flushMinimapViewportDrag).toHaveBeenCalledTimes(1);
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(2);
});
it('allows middle-button dragging and rejects right-button dragging in select, hand, and Space modes', () => {
render(<StageInteractionsHarness />);
const viewport = screen.getByTestId('viewport');
act(() => {
fireEvent(
viewport,
new MouseEvent('pointerdown', {
bubbles: true,
clientX: 100,
clientY: 100,
button: 1,
buttons: 4,
}),
);
});
expect(screen.getByTestId('panning').textContent).toBe('true');
act(() => {
fireEvent(
viewport,
new MouseEvent('pointerup', {
bubbles: true,
clientX: 100,
clientY: 100,
button: 1,
buttons: 0,
}),
);
});
expect(screen.getByTestId('panning').textContent).toBe('false');
act(() => {
fireEvent(
viewport,
new MouseEvent('pointerdown', {
bubbles: true,
clientX: 100,
clientY: 100,
button: 2,
buttons: 2,
}),
);
});
expect(screen.getByTestId('panning').textContent).toBe('false');
act(() => {
screen.getByRole('button', { name: '切抓手' }).click();
});
expect(screen.getByTestId('tool').textContent).toBe('hand');
act(() => {
fireEvent(
viewport,
new MouseEvent('pointerdown', {
bubbles: true,
clientX: 100,
clientY: 100,
button: 2,
buttons: 2,
}),
);
});
expect(screen.getByTestId('panning').textContent).toBe('false');
act(() => {
screen.getByRole('button', { name: '松开空格' }).click();
});
act(() => {
screen.getByRole('button', { name: '按住空格' }).click();
});
expect(screen.getByTestId('tool').textContent).toBe('hand');
act(() => {
fireEvent(
viewport,
new MouseEvent('pointerdown', {
bubbles: true,
clientX: 100,
clientY: 100,
button: 2,
buttons: 2,
}),
);
});
expect(screen.getByTestId('panning').textContent).toBe('false');
});
it('rejects right-click panning on layers in select, hand, and Space modes', () => {
render(<StageInteractionsHarness />);
act(() => {
screen.getByRole('button', { name: '右键选第一层' }).click();
});
expect(screen.getByTestId('panning').textContent).toBe('false');
expect(screen.getByTestId('selection').textContent).toBe('-:');
act(() => {
screen.getByRole('button', { name: '右键抓手模式选第一层' }).click();
});
expect(screen.getByTestId('panning').textContent).toBe('false');
expect(screen.getByTestId('tool').textContent).toBe('hand');
expect(screen.getByTestId('selection').textContent).toBe('-:');
act(() => {
screen.getByRole('button', { name: '松开空格' }).click();
});
act(() => {
screen.getByRole('button', { name: '按住空格' }).click();
});
expect(screen.getByTestId('tool').textContent).toBe('hand');
act(() => {
const layer = screen.getByTestId('layer-first');
fireEvent(
layer,
new MouseEvent('pointerdown', {
bubbles: true,
clientX: 40,
clientY: 40,
button: 2,
buttons: 2,
}),
);
});
expect(screen.getByTestId('panning').textContent).toBe('false');
expect(screen.getByTestId('selection').textContent).toBe('-:');
});
it('rejects right-click panning on generation frames in select, hand, and Space modes', () => {
render(<StageInteractionsHarness />);
act(() => {
screen.getByRole('button', { name: '右键拖生成占位' }).click();
});
expect(screen.getByTestId('panning').textContent).toBe('false');
act(() => {
screen.getByRole('button', { name: '右键抓手模式拖生成占位' }).click();
});
expect(screen.getByTestId('panning').textContent).toBe('false');
expect(screen.getByTestId('tool').textContent).toBe('hand');
act(() => {
screen.getByRole('button', { name: '松开空格' }).click();
});
act(() => {
screen.getByRole('button', { name: '按住空格' }).click();
});
expect(screen.getByTestId('tool').textContent).toBe('hand');
act(() => {
const frame = screen.getByTestId('generation-frame');
fireEvent(
frame,
new MouseEvent('pointerdown', {
bubbles: true,
clientX: 300,
clientY: 200,
button: 2,
buttons: 2,
}),
);
});
expect(screen.getByTestId('panning').textContent).toBe('false');
});
});
@@ -92,9 +92,7 @@ type UseImageCanvasStageInteractionsOptions = {
pickUiDesignSpecFromLayer: (layer: CanvasLayer) => void;
pickPublicationReferenceFromLayer: (layer: CanvasLayer) => void;
openLayerGenerationDialog?: (layer: CanvasLayer) => boolean;
activateCanvasGenerationDialog: (
dialog: CanvasGenerationDialogState,
) => void;
activateCanvasGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: (
@@ -216,6 +214,11 @@ export function useImageCanvasStageInteractions({
}
}, [finishViewportInteraction, flushMinimapViewportDrag]);
const rejectRightButtonInteraction = useCallback(() => {
// Preserve the pointer event so the matching contextmenu event can still open.
clearActiveInteraction();
}, [clearActiveInteraction]);
const setShiftPressed = useCallback((pressed: boolean) => {
isShiftPressedRef.current = pressed;
}, []);
@@ -239,12 +242,17 @@ export function useImageCanvasStageInteractions({
const handleCanvasPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const button = getPointerButton(event);
if (button !== 0 || effectiveTool === 'hand') {
if (button === 2) {
rejectRightButtonInteraction();
return;
}
if (button === 1 || (button === 0 && effectiveTool === 'hand')) {
startPan(event);
return;
}
if (button !== 0) {
event.preventDefault();
return;
}
const target = event.target as HTMLElement;
@@ -269,18 +277,29 @@ export function useImageCanvasStageInteractions({
}
clearCanvasFocus();
},
[canvasViewportRef, clearCanvasFocus, effectiveTool, startPan],
[
canvasViewportRef,
clearCanvasFocus,
effectiveTool,
rejectRightButtonInteraction,
startPan,
],
);
const handleLayerPointerDown = useCallback(
(event: ReactPointerEvent<HTMLElement>, layer: CanvasLayer) => {
const button = getPointerButton(event);
if (button === 1 || effectiveTool === 'hand') {
if (button === 2) {
rejectRightButtonInteraction();
return;
}
if (button === 1 || (button === 0 && effectiveTool === 'hand')) {
event.stopPropagation();
startPan(event);
return;
}
if (button !== 0) {
event.preventDefault();
event.stopPropagation();
return;
}
@@ -409,6 +428,7 @@ export function useImageCanvasStageInteractions({
pickIconSpecFromLayer,
pickPublicationReferenceFromLayer,
pickUiDesignSpecFromLayer,
rejectRightButtonInteraction,
selectedLayerIds,
setGenerateDialog,
setSelectedLayerId,
@@ -481,12 +501,18 @@ export function useImageCanvasStageInteractions({
return;
}
const button = getPointerButton(event);
if (button === 1 || effectiveTool === 'hand') {
if (button === 2) {
rejectRightButtonInteraction();
return;
}
if (button === 1 || (button === 0 && effectiveTool === 'hand')) {
event.stopPropagation();
startPan(event);
return;
}
if (button !== 0) {
event.preventDefault();
event.stopPropagation();
return;
}
@@ -527,6 +553,7 @@ export function useImageCanvasStageInteractions({
canvasGenerationDialogs,
effectiveTool,
layers,
rejectRightButtonInteraction,
selectedLayerIds,
setSelectedLayerId,
setSelectedLayerIds,
@@ -554,6 +581,9 @@ export function useImageCanvasStageInteractions({
const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if ((event.buttons & 2) !== 0 && (event.buttons & (1 | 4)) === 0) {
return;
}
if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) {
event.preventDefault();
const rect = canvasViewportRef.current?.getBoundingClientRect();