fix(canvas): pan horizontally with Shift+wheel (#523)

- Convert primarily vertical Shift+wheel input to horizontal panning
- Preserve native horizontal trackpad deltas instead of swapping both axes
- Keep Ctrl and Meta wheel gestures on the existing zoom path
This commit is contained in:
Danila Poyarkov 2026-08-14 20:58:28 +03:00 committed by GitHub
parent d67415c793
commit f781c48eaa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 76 additions and 10 deletions

View file

@ -35,6 +35,7 @@
### Changed
- Pan horizontally with Shift+wheel while preserving native horizontal trackpad movement.
- Move MCP connections into their own Settings destination instead of presenting them as part of model configuration.
### Performance

View file

@ -5,6 +5,9 @@ import type { Editor } from '@open-pencil/core/editor'
import { createRafScheduler } from '#vue/shared/input/raf-scheduler'
const WHEEL_DELTA_LINE = 1
const WHEEL_DELTA_PAGE = 2
type WheelAccum = {
deltaX: number
deltaY: number
@ -14,22 +17,37 @@ type WheelAccum = {
hasZoom: boolean
}
type WheelPanInput = Pick<WheelEvent, 'deltaX' | 'deltaY' | 'deltaMode' | 'shiftKey'>
type WheelModifierInput = Pick<WheelEvent, 'ctrlKey' | 'metaKey'>
export type WheelPanDelta = { dx: number; dy: number }
function isMacOs() {
return typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform)
}
function normalizeWheelDelta(e: WheelEvent): { dx: number; dy: number } {
let { deltaX, deltaY } = e
if (e.deltaMode === WheelEvent.DOM_DELTA_LINE) {
function normalizedWheelDelta(event: WheelPanInput): WheelPanDelta {
let { deltaX, deltaY } = event
if (event.deltaMode === WHEEL_DELTA_LINE) {
deltaX *= 40
deltaY *= 40
} else if (e.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
} else if (event.deltaMode === WHEEL_DELTA_PAGE) {
deltaX *= 800
deltaY *= 800
}
return { dx: deltaX, dy: deltaY }
}
export function isWheelZoom(event: WheelModifierInput): boolean {
return event.ctrlKey || event.metaKey
}
export function wheelPanDelta(event: WheelPanInput): WheelPanDelta {
const delta = normalizedWheelDelta(event)
if (!event.shiftKey || Math.abs(delta.dx) >= Math.abs(delta.dy)) return delta
return { dx: delta.dy, dy: 0 }
}
const WHEEL_ZOOM_SPEED = 1.25
function wheelDeltaModeScale(event: WheelEvent) {
@ -71,18 +89,18 @@ export function setupWheelPanZoom(canvasRef: Ref<HTMLCanvasElement | null>, edit
const wheelScheduler = createRafScheduler(flushWheel)
function onWheel(e: WheelEvent) {
function onWheel(event: WheelEvent) {
const canvas = canvasRef.value
if (!canvas) return
const { dx, dy } = normalizeWheelDelta(e)
if (e.ctrlKey || e.metaKey) {
if (isWheelZoom(event)) {
const rect = canvas.getBoundingClientRect()
wheelAccum.zoomCenterX = e.clientX - rect.left
wheelAccum.zoomCenterY = e.clientY - rect.top
wheelAccum.zoomScale *= 2 ** wheelZoomDelta(e)
wheelAccum.zoomCenterX = event.clientX - rect.left
wheelAccum.zoomCenterY = event.clientY - rect.top
wheelAccum.zoomScale *= 2 ** wheelZoomDelta(event)
wheelAccum.hasZoom = true
} else {
const { dx, dy } = wheelPanDelta(event)
wheelAccum.deltaX -= dx
wheelAccum.deltaY -= dy
}

View file

@ -0,0 +1,47 @@
import { describe, expect, test } from 'bun:test'
import { isWheelZoom, wheelPanDelta } from '#vue/shared/input/wheel'
function wheelInput(
deltaX: number,
deltaY: number,
options: { deltaMode?: number; shiftKey?: boolean } = {}
) {
return {
deltaX,
deltaY,
deltaMode: options.deltaMode ?? 0,
shiftKey: options.shiftKey ?? false
}
}
describe('wheel navigation', () => {
test('preserves ordinary vertical and horizontal movement', () => {
expect(wheelPanDelta(wheelInput(0, 24))).toEqual({ dx: 0, dy: 24 })
expect(wheelPanDelta(wheelInput(18, 3))).toEqual({ dx: 18, dy: 3 })
})
test('converts a vertical Shift+wheel gesture to horizontal movement', () => {
expect(wheelPanDelta(wheelInput(0, 24, { shiftKey: true }))).toEqual({ dx: 24, dy: 0 })
expect(wheelPanDelta(wheelInput(2, -16, { shiftKey: true }))).toEqual({ dx: -16, dy: 0 })
})
test('keeps native horizontal Shift+trackpad movement horizontal', () => {
expect(wheelPanDelta(wheelInput(20, 2, { shiftKey: true }))).toEqual({ dx: 20, dy: 2 })
expect(wheelPanDelta(wheelInput(-12, 0, { shiftKey: true }))).toEqual({ dx: -12, dy: 0 })
})
test('normalizes line and page delta modes before axis mapping', () => {
expect(wheelPanDelta(wheelInput(0, 2, { deltaMode: 1, shiftKey: true }))).toEqual({
dx: 80,
dy: 0
})
expect(wheelPanDelta(wheelInput(1, 0, { deltaMode: 2 }))).toEqual({ dx: 800, dy: 0 })
})
test('gives Ctrl and Meta zoom precedence independently of Shift', () => {
expect(isWheelZoom({ ctrlKey: true, metaKey: false })).toBeTrue()
expect(isWheelZoom({ ctrlKey: false, metaKey: true })).toBeTrue()
expect(isWheelZoom({ ctrlKey: false, metaKey: false })).toBeFalse()
})
})