fix(canvas): keep card drags alive on logos and pan with the middle mouse button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-26 18:31:02 +01:00
co-authored by Claude Opus 5.5
parent e1637cbe02
commit f751365863
3 changed files with 94 additions and 5 deletions
+6
View File
@@ -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 <html> by applyStoredTheme() from
+29 -5
View File
@@ -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 };
+59
View File
@@ -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);
});