调整画布拖拽交互仅保留鼠标中键 #88
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user