diff --git a/docs/configuration/viewport.md b/docs/configuration/viewport.md index 40fbeb6..eccff72 100644 --- a/docs/configuration/viewport.md +++ b/docs/configuration/viewport.md @@ -41,6 +41,8 @@ flowCanvas({ If you reach the level some other way — the wheel, `setViewport()` — there is no remembered viewport to go back to, so a double-click there zooms out to `minZoom` about the cursor instead of doing nothing. Panning or zooming by hand discards the remembered viewport, so a later toggle-out never jumps to a view you have since left. +The canvas's own chrome is not part of the gesture: a double-click on the minimap or the controls panel does what that panel does and nothing else. Two quick presses of zoom-in are two zoom steps, not a jump to the double-click level. + Two things to know about `'toggle'`: - `dblClickZoomLevel` must sit above `minZoom`, otherwise there is no room to zoom back out into. If it does not (because clamping pushed it onto `minZoom`), AlpineFlow keeps d3's stepped handler rather than installing a gesture that would stall. diff --git a/src/core/controls-panel-fullscreen.test.ts b/src/core/controls-panel-fullscreen.test.ts index b83197e..161cac6 100644 --- a/src/core/controls-panel-fullscreen.test.ts +++ b/src/core/controls-panel-fullscreen.test.ts @@ -74,3 +74,45 @@ describe('controls panel — fullscreen button', () => { expect(container.querySelector('.flow-controls')).toBeNull(); }); }); + +describe('controls panel — the canvas gestures underneath', () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + // The panel already swallows mousedown, pointerdown and wheel so the canvas does not pan or + // zoom under it. Double-click was the one it let through: two quick presses of zoom-in are a + // double-click to the container, so the canvas jumped to the double-click level instead of + // taking a second step under the cursor. + it.each([ + ['mousedown', () => new MouseEvent('mousedown', { bubbles: true, cancelable: true })], + ['pointerdown', () => new MouseEvent('pointerdown', { bubbles: true, cancelable: true })], + ['wheel', () => new WheelEvent('wheel', { bubbles: true, cancelable: true })], + ['dblclick', () => new MouseEvent('dblclick', { bubbles: true, cancelable: true })], + ])('keeps %s inside the panel', (type, make) => { + createControlsPanel(container, baseOptions()); + let reachedCanvas = 0; + container.addEventListener(type, () => { reachedCanvas++; }); + + container.querySelector('.flow-controls button')!.dispatchEvent(make()); + + expect(reachedCanvas).toBe(0); + }); + + it('still runs the button\'s own handler on a double-click\'s first press', () => { + // Swallowing the event at the panel must not disarm the control it landed on. + const onZoomIn = vi.fn(); + createControlsPanel(container, { ...baseOptions(), onZoomIn }); + + const btn = container.querySelector('.flow-controls button') as HTMLElement; + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + btn.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })); + + expect(onZoomIn).toHaveBeenCalledTimes(2); + }); +}); + diff --git a/src/core/controls-panel.ts b/src/core/controls-panel.ts index c6f710e..67ed3c5 100644 --- a/src/core/controls-panel.ts +++ b/src/core/controls-panel.ts @@ -121,6 +121,10 @@ export function createControlsPanel( wrapper.addEventListener('mousedown', (e) => e.stopPropagation()); wrapper.addEventListener('pointerdown', (e) => e.stopPropagation()); wrapper.addEventListener('wheel', (e) => e.stopPropagation(), { passive: false }); + // Double-click too: the zoom handler sits on the container, and two quick presses of + // zoom-in are a double-click as far as it is concerned — so the canvas jumped to the + // double-click level instead of taking the second step under the cursor. + wrapper.addEventListener('dblclick', (e) => e.stopPropagation()); container.appendChild(wrapper); diff --git a/src/core/minimap.test.ts b/src/core/minimap.test.ts index 7d55996..06ad7c7 100644 --- a/src/core/minimap.test.ts +++ b/src/core/minimap.test.ts @@ -68,3 +68,51 @@ describe('minimap lean viewport getter', () => { expect(stateCalls).toBe(before + 1); // fallback path preserves old behaviour }); }); + +describe('minimap and the canvas double-click gesture', () => { + // Double-click zoom is bound to the container, and the minimap sits inside it. Two clicks on + // the minimap are two pans — the canvas jumping to another scale on top of them is the + // container answering a gesture that was never aimed at it. + const mounted = () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const mm = createMiniMap(container, { + getState: () => fullState(), + setViewport: () => {}, + config: { minimapPannable: true }, + }); + mm.render(); + + return { container, mm }; + }; + + it('does not let a double-click reach the canvas', () => { + const { container, mm } = mounted(); + let reachedCanvas = 0; + container.addEventListener('dblclick', () => { reachedCanvas++; }); + + container.querySelector('.flow-minimap svg')! + .dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })); + + expect(reachedCanvas).toBe(0); + + mm.destroy(); + container.remove(); + }); + + it('still lets a double-click anywhere else through', () => { + const { container, mm } = mounted(); + const elsewhere = document.createElement('div'); + container.appendChild(elsewhere); + let reachedCanvas = 0; + container.addEventListener('dblclick', () => { reachedCanvas++; }); + + elsewhere.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })); + + expect(reachedCanvas).toBe(1); + + mm.destroy(); + container.remove(); + }); +}); + diff --git a/src/core/minimap.ts b/src/core/minimap.ts index 63dbd5b..f698fae 100644 --- a/src/core/minimap.ts +++ b/src/core/minimap.ts @@ -232,6 +232,15 @@ export function createMiniMap( svg.addEventListener('pointermove', onPointerMove); svg.addEventListener('pointerup', onPointerUp); + // The minimap owns a double-click on itself: two clicks in it are two pans, and the canvas's + // double-click zoom sits on the container, so without this the second one also toggled the + // zoom — a click meant to move the view jumped it to another scale as well. + function onDblClick(e: MouseEvent): void { + e.stopPropagation(); + } + + wrapper.addEventListener('dblclick', onDblClick); + // ── Zoom interaction ─────────────────────────────────────────────── function onWheel(e: WheelEvent): void { if (!config.minimapZoomable) { @@ -256,6 +265,7 @@ export function createMiniMap( svg.removeEventListener('pointermove', onPointerMove); svg.removeEventListener('pointerup', onPointerUp); svg.removeEventListener('wheel', onWheel); + wrapper.removeEventListener('dblclick', onDblClick); wrapper.remove(); }