diff --git a/src/components/image-editor/ImageCanvasStageInteractionModel.test.ts b/src/components/image-editor/ImageCanvasStageInteractionModel.test.ts
index 40625737c..c05ae0ec7 100644
--- a/src/components/image-editor/ImageCanvasStageInteractionModel.test.ts
+++ b/src/components/image-editor/ImageCanvasStageInteractionModel.test.ts
@@ -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 },
diff --git a/src/components/image-editor/ImageCanvasStageInteractionModel.ts b/src/components/image-editor/ImageCanvasStageInteractionModel.ts
index 08ceb1a0d..19445fc06 100644
--- a/src/components/image-editor/ImageCanvasStageInteractionModel.ts
+++ b/src/components/image-editor/ImageCanvasStageInteractionModel.ts
@@ -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
diff --git a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx
index 0db87d40a..acb03151a 100644
--- a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx
+++ b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx
@@ -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({
{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('|')}
@@ -323,7 +326,9 @@ function StageInteractionsHarness({
{String(interaction.isPanning)}
{interaction.effectiveTool}
- {interaction.snapGuide?.vertical ?? interaction.snapGuide?.horizontal ?? '-'}
+ {interaction.snapGuide?.vertical ??
+ interaction.snapGuide?.horizontal ??
+ '-'}
{generateDialog?.placeholder
@@ -337,9 +342,7 @@ function StageInteractionsHarness({
{String(generateDialog?.composerOpen ?? false)}
{clearCount}
-
- {imageMenuCloseCount}
-
+ {imageMenuCloseCount}
+
+
+
+
);
}
@@ -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();
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();
+
+ 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();
+
+ 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();
+
+ 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');
+ });
});
diff --git a/src/components/image-editor/useImageCanvasStageInteractions.ts b/src/components/image-editor/useImageCanvasStageInteractions.ts
index 1b94876b2..63ad4c603 100644
--- a/src/components/image-editor/useImageCanvasStageInteractions.ts
+++ b/src/components/image-editor/useImageCanvasStageInteractions.ts
@@ -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) => {
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, 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) => {
+ if ((event.buttons & 2) !== 0 && (event.buttons & (1 | 4)) === 0) {
+ return;
+ }
if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) {
event.preventDefault();
const rect = canvasViewportRef.current?.getBoundingClientRect();