diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a02aefc..30003dcbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/vue/src/shared/input/wheel.ts b/packages/vue/src/shared/input/wheel.ts index 5bd84f71b..f84426ab0 100644 --- a/packages/vue/src/shared/input/wheel.ts +++ b/packages/vue/src/shared/input/wheel.ts @@ -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 +type WheelModifierInput = Pick + +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, 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 } diff --git a/tests/engine/vue/shared/wheel.test.ts b/tests/engine/vue/shared/wheel.test.ts new file mode 100644 index 000000000..3e6109591 --- /dev/null +++ b/tests/engine/vue/shared/wheel.test.ts @@ -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() + }) +})