Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/configuration/viewport.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions src/core/controls-panel-fullscreen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

4 changes: 4 additions & 0 deletions src/core/controls-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
48 changes: 48 additions & 0 deletions src/core/minimap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

10 changes: 10 additions & 0 deletions src/core/minimap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
}

Expand Down