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();