From f7513658637c19635dbcded93fa60f3fd330ffed Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:31:02 +0100 Subject: [PATCH] fix(canvas): keep card drags alive on logos and pan with the middle mouse button Co-Authored-By: Claude Opus 5.5 --- resources/css/app.css | 6 ++++ resources/js/canvas-core.js | 34 +++++++++++++++--- resources/js/canvas-core.test.js | 59 ++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 resources/js/canvas-core.test.js diff --git a/resources/css/app.css b/resources/css/app.css index 3948fa7d6f..8d30890955 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -6156,6 +6156,12 @@ html.dark .canvas-zoom-max { background-image: var(--canvas-dots-image); } +/* A pan (canvas-core panZoom), also a middle-button pan that started on a card. */ +:is([data-ec-viewport], .cv-viewport).panning, +:is([data-ec-viewport], .cv-viewport).panning * { + cursor: grabbing !important; +} + /* "Floating" navigation (Appearance → Navigation style): the top bar, sidebar, tab strip, and settings rail float as framed panels over a dotted backdrop, like the canvas islands. Set on by applyStoredTheme() from diff --git a/resources/js/canvas-core.js b/resources/js/canvas-core.js index 4f2127df1c..b23870d3a2 100644 --- a/resources/js/canvas-core.js +++ b/resources/js/canvas-core.js @@ -108,9 +108,9 @@ export function zoomShortcut(e) { } /** - * Wheel zoom (ctrl/cmd) and pan, and drag-to-pan on the viewport background. + * Wheel zoom (ctrl/cmd) and pan, drag-to-pan on the viewport background, and middle-button drag-to-pan anywhere. * getView() returns the current { x, y, z }, which is changed in place. - * canPan(event) decides which presses pan; onPanEnd(moved) runs on release. + * canPan(event) decides which left-button presses pan; onPanEnd(moved) runs on their release. */ export function panZoom({ root, viewport, world, zoomLabel, getView, onChange, canPan, onPanStart, onPanEnd }) { function apply() { @@ -196,8 +196,18 @@ export function panZoom({ root, viewport, world, zoomLabel, getView, onChange, c viewport.addEventListener('pointerup', liftFinger, true); viewport.addEventListener('pointercancel', liftFinger, true); - viewport.addEventListener('pointerdown', e => { - if (e.button !== 0 || pinch || !canPan(e)) return; + // A native drag (card logos, links) cancels the pointer, so the card or pan drag stops after a few pixels. + viewport.addEventListener('dragstart', e => e.preventDefault()); + // A middle-button pan that moved does not also open the link under the pointer in a new tab. + let panned = false; + viewport.addEventListener('auxclick', e => { + if (!panned) return; + panned = false; + e.preventDefault(); + e.stopPropagation(); + }, true); + + function startPan(e, middle) { onPanStart?.(); const view = getView(); const start = { cx: e.clientX, cy: e.clientY, x: view.x, y: view.y }; @@ -220,11 +230,25 @@ export function panZoom({ root, viewport, world, zoomLabel, getView, onChange, c viewport.removeEventListener('pointermove', move); viewport.removeEventListener('pointerup', up); viewport.removeEventListener('pointercancel', up); - onPanEnd?.(moved); + if (middle) panned = moved; + else onPanEnd?.(moved); }; viewport.addEventListener('pointermove', move); viewport.addEventListener('pointerup', up); viewport.addEventListener('pointercancel', up); + } + // The middle mouse button pans from anywhere (also from cards and frames). + // Capture phase: the press does not reach the card, zone or link drags. + viewport.addEventListener('pointerdown', e => { + panned = false; // the auxclick of the last pan did not come (released elsewhere) + if (pinch || e.pointerType !== 'mouse' || e.button !== 1) return; + e.preventDefault(); // no autoscroll on middle press + e.stopPropagation(); + startPan(e, true); + }, true); + viewport.addEventListener('pointerdown', e => { + if (e.button !== 0 || pinch || !canPan(e)) return; + startPan(e, false); }); return { apply, toWorld, zoomTo }; diff --git a/resources/js/canvas-core.test.js b/resources/js/canvas-core.test.js new file mode 100644 index 0000000000..0c48a582c7 --- /dev/null +++ b/resources/js/canvas-core.test.js @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { panZoom } from './canvas-core.js'; + +const rect = () => ({ left: 0, top: 0, width: 800, height: 600 }); +const fakeElement = () => Object.assign(new EventTarget(), { + style: { setProperty() {} }, + classList: { add() {}, remove() {}, toggle() {} }, + getBoundingClientRect: rect, + setPointerCapture() {}, +}); + +/** A viewport whose presses never start a left-button pan (they land on cards). */ +function canvas() { + const viewport = fakeElement(); + const view = { x: 0, y: 0, z: 1 }; + const ends = []; + panZoom({ root: fakeElement(), viewport, world: { style: {} }, zoomLabel: {}, getView: () => view, canPan: () => false, onPanEnd: moved => ends.push(moved) }); + const fire = (type, props = {}) => { + const event = Object.assign(new Event(type, { cancelable: true }), { pointerId: 1, pointerType: 'mouse', button: 0, clientX: 0, clientY: 0 }, props); + viewport.dispatchEvent(event); + return event; + }; + return { view, ends, fire }; +} + +test('the middle mouse button pans from a card, and the release does not open a link', () => { + const { view, ends, fire } = canvas(); + assert.equal(fire('pointerdown', { button: 1, clientX: 10, clientY: 10 }).defaultPrevented, true, 'no autoscroll'); + fire('pointermove', { button: 1, clientX: 60, clientY: 40 }); + fire('pointerup', { button: 1, clientX: 60, clientY: 40 }); + + assert.deepEqual({ x: view.x, y: view.y }, { x: 50, y: 30 }); + assert.deepEqual(ends, [], 'a middle pan does not close the side panel'); + assert.equal(fire('auxclick', { button: 1 }).defaultPrevented, true); +}); + +test('a middle click without a move still opens the link under the pointer', () => { + const { fire } = canvas(); + fire('pointerdown', { button: 1, clientX: 10, clientY: 10 }); + fire('pointerup', { button: 1, clientX: 10, clientY: 10 }); + + assert.equal(fire('auxclick', { button: 1 }).defaultPrevented, false); +}); + +test('a left-button press on a card does not pan', () => { + const { view, fire } = canvas(); + fire('pointerdown', { clientX: 10, clientY: 10 }); + fire('pointermove', { clientX: 60, clientY: 40 }); + fire('pointerup', { clientX: 60, clientY: 40 }); + + assert.deepEqual({ x: view.x, y: view.y }, { x: 0, y: 0 }); +}); + +test('a native drag (card logo, link) cannot start and cancel the card drag', () => { + const { fire } = canvas(); + + assert.equal(fire('dragstart').defaultPrevented, true); +});