Skip to content

Commit afc7dee

Browse files
authored
fix(canvas): support middle mouse panning (CoreBunch#176)
1 parent 5f294d7 commit afc7dee

3 files changed

Lines changed: 146 additions & 9 deletions

File tree

src/__tests__/canvas/useCanvasWheelSync.test.tsx

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { act, cleanup as cleanupRender, fireEvent, render, screen } from '@testi
44
import { useEditorStore } from '@site/store/store'
55
import { RESET_ZOOM } from '@site/canvas/math'
66
import {
7+
isCanvasPointerPanActive,
78
isCanvasSpacePanActive,
89
panDeltaFromWheel,
910
setCanvasSpacePanActive,
@@ -39,6 +40,21 @@ function dispatchWheel(target: Element, props: Record<string, unknown>) {
3940
return event
4041
}
4142

43+
function dispatchPointer(target: Element, type: string, props: Record<string, unknown>) {
44+
const event = new Event(type, { bubbles: true, cancelable: true })
45+
for (const [key, value] of Object.entries(props)) {
46+
Object.defineProperty(event, key, { configurable: true, value })
47+
}
48+
target.dispatchEvent(event)
49+
return event
50+
}
51+
52+
function parseTranslate(transform: string): { x: number; y: number } {
53+
const match = /^translate\((-?\d+)px, (-?\d+)px\) scale\(1\)$/.exec(transform)
54+
if (!match) throw new Error(`Unexpected transform: ${transform}`)
55+
return { x: Number(match[1]), y: Number(match[2]) }
56+
}
57+
4258
beforeEach(() => {
4359
useEditorStore.setState({
4460
zoom: RESET_ZOOM,
@@ -124,6 +140,55 @@ describe('useCanvas wheel pan sync', () => {
124140
expect(layer.style.transform).toContain('scale(1.')
125141
expect(layer.style.transform).not.toBe('translate(0px, 0px) scale(1)')
126142
})
143+
144+
it('pans the canvas with middle-mouse dragging', async () => {
145+
render(<TestCanvas />)
146+
147+
const root = screen.getByTestId('test-canvas-root')
148+
const layer = screen.getByTestId('test-transform-layer')
149+
150+
fireEvent.pointerDown(root, {
151+
pointerId: 1,
152+
pointerType: 'mouse',
153+
button: 1,
154+
buttons: 4,
155+
clientX: 100,
156+
clientY: 100,
157+
})
158+
fireEvent.pointerMove(root, {
159+
pointerId: 1,
160+
pointerType: 'mouse',
161+
button: -1,
162+
buttons: 4,
163+
clientX: 130,
164+
clientY: 140,
165+
})
166+
167+
await act(async () => {
168+
await nextAnimationFrame()
169+
})
170+
171+
const translate = parseTranslate(layer.style.transform)
172+
expect(translate.x).toBeGreaterThan(0)
173+
expect(translate.y).toBeGreaterThan(0)
174+
})
175+
176+
it('prevents browser defaults when middle-mouse pan starts', () => {
177+
render(<TestCanvas />)
178+
179+
const root = screen.getByTestId('test-canvas-root')
180+
181+
const pointerDown = dispatchPointer(root, 'pointerdown', {
182+
pointerId: 1,
183+
pointerType: 'mouse',
184+
button: 1,
185+
buttons: 4,
186+
clientX: 100,
187+
clientY: 100,
188+
})
189+
190+
expect(pointerDown.defaultPrevented).toBe(true)
191+
})
127192
})
128193

129194
describe('canvas mouse pan input policy', () => {
@@ -144,9 +209,13 @@ describe('canvas mouse pan input policy', () => {
144209
expect(panDeltaFromWheel({ shiftKey: false, deltaX: 0, deltaY: 120 })).toEqual({ dx: 0, dy: -120 })
145210
})
146211

147-
it('does not use middle-button dragging as a canvas pan gesture', () => {
148-
expect(shouldStartCanvasPointerPan({ button: 1 }, { spaceHeld: false })).toBe(false)
149-
expect(shouldStartCanvasPointerPan({ button: 1 }, { spaceHeld: true })).toBe(false)
212+
it('uses middle-button dragging as a canvas pan gesture', () => {
213+
expect(shouldStartCanvasPointerPan({ button: 1 }, { spaceHeld: false })).toBe(true)
214+
expect(shouldStartCanvasPointerPan({ button: 1 }, { spaceHeld: true })).toBe(true)
215+
expect(shouldStartCanvasPointerPan({ button: 0 }, { spaceHeld: false })).toBe(false)
150216
expect(shouldStartCanvasPointerPan({ button: 0 }, { spaceHeld: true })).toBe(true)
217+
expect(isCanvasPointerPanActive({ buttons: 4 }, { spaceHeld: false })).toBe(true)
218+
expect(isCanvasPointerPanActive({ buttons: 1 }, { spaceHeld: false })).toBe(false)
219+
expect(isCanvasPointerPanActive({ buttons: 1 }, { spaceHeld: true })).toBe(true)
151220
})
152221
})

src/admin/pages/site/canvas/canvasPanInput.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ interface PointerPanEvent {
88
button: number
99
}
1010

11+
interface PointerPanState {
12+
buttons: number
13+
}
14+
1115
interface PointerPanOptions {
1216
spaceHeld: boolean
1317
}
@@ -19,6 +23,16 @@ const CANVAS_SPACE_PAN_DATA_KEYS: Record<CanvasSpacePanSource, string> = {
1923
iframe: 'instaticCanvasIframeSpacePan',
2024
}
2125

26+
const PRIMARY_MOUSE_BUTTON = 0
27+
const MIDDLE_MOUSE_BUTTON = 1
28+
const PRIMARY_MOUSE_BUTTON_MASK = 1
29+
const MIDDLE_MOUSE_BUTTON_MASK = 4
30+
31+
export const CANVAS_DRAG_PAN_BUTTONS = [
32+
PRIMARY_MOUSE_BUTTON_MASK,
33+
MIDDLE_MOUSE_BUTTON_MASK,
34+
] as const
35+
2236
export function panDeltaFromWheel(event: WheelPanEvent): { dx: number; dy: number } {
2337
const wheelX = event.shiftKey && event.deltaX === 0 ? event.deltaY : event.deltaX
2438
const wheelY = event.shiftKey ? 0 : event.deltaY
@@ -29,7 +43,21 @@ export function shouldStartCanvasPointerPan(
2943
event: PointerPanEvent,
3044
{ spaceHeld }: PointerPanOptions,
3145
): boolean {
32-
return spaceHeld && event.button === 0
46+
return event.button === MIDDLE_MOUSE_BUTTON || (spaceHeld && event.button === PRIMARY_MOUSE_BUTTON)
47+
}
48+
49+
export function isCanvasPointerPanActive(
50+
event: PointerPanState,
51+
{ spaceHeld }: PointerPanOptions,
52+
): boolean {
53+
return (
54+
(event.buttons & MIDDLE_MOUSE_BUTTON_MASK) !== 0 ||
55+
(spaceHeld && (event.buttons & PRIMARY_MOUSE_BUTTON_MASK) !== 0)
56+
)
57+
}
58+
59+
export function isMiddleMousePointerPan(event: PointerPanState): boolean {
60+
return (event.buttons & MIDDLE_MOUSE_BUTTON_MASK) !== 0
3361
}
3462

3563
export function setCanvasSpacePanActive(

src/admin/pages/site/hooks/useCanvas.ts

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@ import {
3030
incrementalScaleFromPinchMovement,
3131
} from '@site/canvas/math'
3232
import { panToCenterBreakpointFrame } from '@site/canvas/canvasDomGeometry'
33-
import { panDeltaFromWheel, setCanvasSpacePanActive } from '@site/canvas/canvasPanInput'
33+
import {
34+
CANVAS_DRAG_PAN_BUTTONS,
35+
isCanvasPointerPanActive,
36+
isMiddleMousePointerPan,
37+
panDeltaFromWheel,
38+
setCanvasSpacePanActive,
39+
} from '@site/canvas/canvasPanInput'
3440

3541
interface Transform {
3642
zoom: number
@@ -430,11 +436,17 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
430436

431437
// ─── Gesture handlers ─────────────────────────────────────────────────────
432438

433-
const bind = useGesture(
439+
const gestureBind = useGesture(
434440
{
435-
onDrag: ({ delta: [dx, dy], buttons, first, last }) => {
441+
onDrag: ({ delta: [dx, dy], buttons, first, last, event }) => {
436442
if (first) {
437-
isDraggingRef.current = spaceActiveRef.current && (buttons & 1) !== 0
443+
isDraggingRef.current = isCanvasPointerPanActive(
444+
{ buttons },
445+
{ spaceHeld: spaceActiveRef.current },
446+
)
447+
if (isDraggingRef.current && isMiddleMousePointerPan({ buttons }) && event.cancelable) {
448+
event.preventDefault()
449+
}
438450
}
439451

440452
if (!isDraggingRef.current) return
@@ -472,7 +484,10 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
472484
},
473485
},
474486
{
475-
drag: { filterTaps: true },
487+
drag: {
488+
filterTaps: true,
489+
pointer: { buttons: [...CANVAS_DRAG_PAN_BUTTONS] },
490+
},
476491
pinch: {
477492
eventOptions: { passive: false },
478493
// Trackpad pinch already arrives here through the native ctrl/meta
@@ -483,6 +498,31 @@ export function useCanvas({ canvasRootRef, transformLayerRef, enabled }: UseCanv
483498
},
484499
)
485500

501+
const bind = () => {
502+
const gestureHandlers = gestureBind()
503+
return {
504+
...gestureHandlers,
505+
onPointerDown: (event: React.PointerEvent<HTMLElement>) => {
506+
if (isMiddleMousePointerPan({ buttons: event.buttons }) && event.cancelable) {
507+
event.preventDefault()
508+
}
509+
gestureHandlers.onPointerDown?.(event)
510+
},
511+
onMouseDown: (event: React.MouseEvent<HTMLElement>) => {
512+
if (event.button === 1 && event.cancelable) {
513+
event.preventDefault()
514+
}
515+
gestureHandlers.onMouseDown?.(event)
516+
},
517+
onAuxClick: (event: React.MouseEvent<HTMLElement>) => {
518+
if (event.button === 1 && event.cancelable) {
519+
event.preventDefault()
520+
}
521+
gestureHandlers.onAuxClick?.(event)
522+
},
523+
}
524+
}
525+
486526
// ─── Cleanup on unmount ───────────────────────────────────────────────────
487527

488528
useEffect(() => {

0 commit comments

Comments
 (0)