diff --git a/docs/configuration/viewport.md b/docs/configuration/viewport.md index 40fbeb6..7f07d30 100644 --- a/docs/configuration/viewport.md +++ b/docs/configuration/viewport.md @@ -20,6 +20,7 @@ order: 6 | `zoomActivationKeyCode` | `string \| null` | `null` | Key that forces zoom-on-wheel, overriding `panOnScroll`. | | `zoomOnDoubleClick` | `boolean \| 'step' \| 'toggle'` | `true` | `true`/`'step'` = d3's stepped zoom, `'toggle'` = jump-to-level and back, `false` = disabled. See [Double-click zoom](#double-click-zoom). | | `dblClickZoomLevel` | `number` | `1.5` | Level `'toggle'` mode animates to. Clamped to `[minZoom, maxZoom]`; ignored in `'step'` mode. | +| `dblClickZoomOutLevel` | `number \| 'fit' \| 'min'` | `'min'` | Where `'toggle'` zooms out to when there is no remembered viewport. `'fit'` frames the whole graph; a number is a fixed level about the cursor. See [Double-click zoom](#double-click-zoom). | | `zoomLevels` | `false \| object` | `{ far: 0.4, medium: 0.75 }` | Contextual zoom thresholds. Sets `data-zoom-level` attribute. See [Contextual zoom](../canvas/contextual-zoom.md). | | `autoPanSpeed` | `number` | `15` | Auto-pan speed multiplier. | | `autoPanOnConnect` | `boolean` | `true` | Auto-pan when drawing connections near canvas edge. | @@ -41,9 +42,29 @@ 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. +### Where the toggle zooms out to + +That fallback is what `dblClickZoomOutLevel` sets. A remembered viewport always wins over it — the option only decides what happens when there is nothing to restore. + +```js +flowCanvas({ + zoomOnDoubleClick: 'toggle', + dblClickZoomLevel: 1, // double-click → 100% about the cursor + dblClickZoomOutLevel: 'fit', // double-click again → the whole graph +}) +``` + +| Value | Second double-click goes to | +|-------|-----------------------------| +| `'min'` (default) | `minZoom`, about the cursor. | +| `'fit'` | The viewport that frames every visible node — what `fitView()` computes. On a canvas with nothing to fit (no nodes, or none measured yet), falls back to `'min'` rather than going dead. | +| `number` | That level, about the cursor. Clamped to `[minZoom, maxZoom]`. | + +`'fit'` suits a canvas people read rather than survey — a workflow, a schema — where the gesture reads as "closer" and then "show me all of it", and where `minZoom` is an arbitrary floor that frames nothing in particular. + 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. +- `dblClickZoomLevel` must sit above the level it zooms back out to (`minZoom`, or a numeric `dblClickZoomOutLevel`), otherwise there is no room to zoom back out into. If it does not, AlpineFlow keeps d3's stepped handler rather than installing a gesture that would stall. - Like `'step'`, it stays live under `zoomable: false` — that flag gates pointer-gesture zooming (wheel, pinch), never double-click, so a canvas that disables wheel zoom to run its own (e.g. pinch-only via `ctrl`+wheel) keeps the double-click gesture in either mode. Disable double-click zoom itself with `zoomOnDoubleClick: false`. **`false`** — no double-click zoom at all. diff --git a/src/core/pan-zoom-dblclick.test.ts b/src/core/pan-zoom-dblclick.test.ts index 961ab63..bfe52f5 100644 --- a/src/core/pan-zoom-dblclick.test.ts +++ b/src/core/pan-zoom-dblclick.test.ts @@ -213,5 +213,80 @@ describe('double-click mode wiring', () => { expect(changes).toBe(0); expect(zoomOf(container).k).toBe(1); }); + + /** + * `dblClickZoomOutLevel` only governs the branch with no remembered viewport — + * reached here by starting above the level, which is exactly how a canvas comes + * back from a restored or fitted viewport. + */ + describe('zoom-out fallback', () => { + it("puts the whole graph back on screen under 'fit'", async () => { + const fitViewport = { x: 24, y: -12, zoom: 0.6 }; + create({ + onTransformChange: () => {}, + ...toggleOpts, + dblClickZoomOutLevel: 'fit', + getFitViewport: () => fitViewport, + }); + stubRect(container); + + // Above the level with nothing remembered: a programmatic setViewport leaves + // no memory behind, same as arriving on a restored viewport. + instance!.setViewport({ x: 200, y: 100, zoom: 1.8 }); + + dblclickAt(container, 100, 50); + await settle(); + + const settled = zoomOf(container); + expect(settled.k).toBeCloseTo(fitViewport.zoom, 5); + expect(settled.x).toBeCloseTo(fitViewport.x, 5); + expect(settled.y).toBeCloseTo(fitViewport.y, 5); + }); + + it('does not measure the graph while the gesture is still zooming in', async () => { + let measured = 0; + create({ + onTransformChange: () => {}, + ...toggleOpts, + dblClickZoomOutLevel: 'fit', + getFitViewport: () => { measured++; return { x: 0, y: 0, zoom: 0.6 }; }, + }); + stubRect(container); + + dblclickAt(container, 100, 50); // from identity: zooms in to the level + await settle(); + + expect(zoomOf(container).k).toBeCloseTo(1.5, 5); + expect(measured).toBe(0); + }); + + it('falls back to d3 when a fixed out-level leaves the toggle no headroom', () => { + // Same rule as a level clamped onto minZoom: the second double-click would + // stall, so keep the stepped handler that still zooms both ways. + create({ + onTransformChange: () => {}, + zoomOnDoubleClick: 'toggle', + minZoom: 0.5, + maxZoom: 2, + dblClickZoomLevel: 1.5, + dblClickZoomOutLevel: 1.5, + }); + + expect(hasD3DblClickZoom(container)).toBe(true); + }); + + it('attaches the toggle when the fixed out-level sits below the level', () => { + create({ + onTransformChange: () => {}, + zoomOnDoubleClick: 'toggle', + minZoom: 0.5, + maxZoom: 2, + dblClickZoomLevel: 1.5, + dblClickZoomOutLevel: 1, + }); + + expect(hasD3DblClickZoom(container)).toBe(false); + }); + }); }); }); diff --git a/src/core/pan-zoom.test.ts b/src/core/pan-zoom.test.ts index bc0ce8e..eee810e 100644 --- a/src/core/pan-zoom.test.ts +++ b/src/core/pan-zoom.test.ts @@ -204,4 +204,99 @@ describe('resolveDblClickZoom — double-click is a toggle, not zoom-in-only', ( expect(next.zoom).toBe(2); }); }); + + // Only the zoom-out fallback is configurable: it is the branch with no viewport to + // go back to. `minZoom` is a floor, which is the right answer for a canvas people + // survey and the wrong one for a canvas they read — there, "show me all of it" + // is the graph's own extent, not an arbitrary level. + describe('zoom-out fallback (dblClickZoomOutLevel)', () => { + const zoomedIn = { x: 40, y: 20, zoom: 2 }; + const fitViewport = { x: 15, y: -8, zoom: 0.72 }; + + it("goes to the fitted viewport under 'fit', pan and all", () => { + const { next, remember } = resolveDblClickZoom(zoomedIn, { x: 100, y: 50 }, { + level: 1.5, minZoom: 0.5, remembered: null, zoomOut: 'fit', fit: () => fitViewport, + }); + + // The whole transform, not just the zoom: fitting is a pan as much as a scale, + // and zooming out about the cursor would leave the graph off-centre. + expect(next).toEqual(fitViewport); + expect(remember).toBeNull(); + }); + + it('falls back to minZoom when there is nothing to fit', () => { + // An empty canvas, or one whose nodes have not been measured yet. minZoom is + // still a move, and a dead gesture is worse than a blunt one. + const { next } = resolveDblClickZoom(zoomedIn, { x: 100, y: 50 }, { + level: 1.5, minZoom: 0.5, remembered: null, zoomOut: 'fit', fit: () => null, + }); + + expect(next.zoom).toBe(0.5); + }); + + it('does not measure the graph on the double-click that zooms in', () => { + // The fit is a thunk for this reason: most double-clicks zoom in, and walking + // every node's bounds for a branch that will not run is work nobody asked for. + let measured = 0; + resolveDblClickZoom({ x: 0, y: 0, zoom: 1 }, { x: 100, y: 50 }, { + level: 1.5, minZoom: 0.5, remembered: null, zoomOut: 'fit', fit: () => { measured++; return fitViewport; }, + }); + + expect(measured).toBe(0); + }); + + it('a remembered viewport still wins over the fallback', () => { + const remembered = { x: 1, y: 2, zoom: 0.9 }; + const { next } = resolveDblClickZoom(zoomedIn, { x: 100, y: 50 }, { + level: 1.5, minZoom: 0.5, remembered, zoomOut: 'fit', fit: () => fitViewport, + }); + + expect(next).toEqual(remembered); + }); + + it('zooms out to a fixed number about the cursor', () => { + const pointer = { x: 100, y: 50 }; + const { next } = resolveDblClickZoom(zoomedIn, pointer, { + level: 1.5, minZoom: 0.5, remembered: null, zoomOut: 0.8, + }); + + expect(next.zoom).toBe(0.8); + const before = { x: (pointer.x - zoomedIn.x) / zoomedIn.zoom, y: (pointer.y - zoomedIn.y) / zoomedIn.zoom }; + const after = { x: (pointer.x - next.x) / next.zoom, y: (pointer.y - next.y) / next.zoom }; + expect(after.x).toBeCloseTo(before.x, 10); + expect(after.y).toBeCloseTo(before.y, 10); + }); + + it('never zooms out below minZoom', () => { + const { next } = resolveDblClickZoom(zoomedIn, { x: 0, y: 0 }, { + level: 1.5, minZoom: 0.5, remembered: null, zoomOut: 0.1, + }); + + expect(next.zoom).toBe(0.5); + }); + + it('is honest when the fixed level leaves no room below the current zoom', () => { + // Same contract as the level === minZoom case above: hand back the very object, + // so a caller can tell "no move" from a transition. + const current = { x: 10, y: 20, zoom: 1.5 }; + const { next, remember } = resolveDblClickZoom(current, { x: 100, y: 50 }, { + level: 1.5, minZoom: 0.5, remembered: null, zoomOut: 1.5, + }); + + expect(next).toBe(current); + expect(remember).toBeNull(); + }); + + it("defaults to 'min', matching the behaviour before the option existed", () => { + const withOption = resolveDblClickZoom(zoomedIn, { x: 100, y: 50 }, { + level: 1.5, minZoom: 0.5, remembered: null, zoomOut: 'min', + }); + const without = resolveDblClickZoom(zoomedIn, { x: 100, y: 50 }, { + level: 1.5, minZoom: 0.5, remembered: null, + }); + + expect(withOption.next).toEqual(without.next); + expect(without.next.zoom).toBe(0.5); + }); + }); }); diff --git a/src/core/pan-zoom.ts b/src/core/pan-zoom.ts index d332af3..e6f4c84 100644 --- a/src/core/pan-zoom.ts +++ b/src/core/pan-zoom.ts @@ -59,6 +59,24 @@ export interface PanZoomOptions { * zoom-in. Clamped to [minZoom, maxZoom]. Only consulted when * `zoomOnDoubleClick: 'toggle'`. Default: 1.5 */ dblClickZoomLevel?: number; + /** Where a toggle double-click zooms out to when there is no remembered viewport + * to restore — the user reached this zoom by wheel or `setViewport`, or panned + * after zooming in and so dropped the memory. + * + * - `'min'` (default) — `minZoom`, about the cursor. + * - `'fit'` — the viewport that frames the whole graph, as `fitView()` computes + * it. Needs {@link PanZoomOptions.getFitViewport}; falls back to `'min'` when + * there is nothing to fit. + * - `number` — a fixed level, about the cursor. Clamped to [minZoom, maxZoom]. + * + * Only consulted when `zoomOnDoubleClick: 'toggle'`; a remembered viewport always + * wins over it. Default: `'min'` */ + dblClickZoomOutLevel?: number | 'fit' | 'min'; + /** Viewport that frames the whole graph, or `null` when there is nothing to frame. + * Called only when a toggle double-click zooms out under + * `dblClickZoomOutLevel: 'fit'` — pan/zoom does not know about nodes, so the + * consumer supplies this. */ + getFitViewport?: () => Viewport | null; /** Key code that temporarily enables panning when held. Default: 'Space' */ panActivationKeyCode?: string | null; /** Key code that forces zoom (overrides panOnScroll) when held with wheel. Default: 'Control'. @@ -181,23 +199,38 @@ function scaleAbout(current: ViewportTransform, px: number, py: number, k: numbe * so the next double-click can put it back exactly; * - at or above `level` with a remembered viewport → restore it; * - at or above `level` with nothing remembered (the user got here by wheel or - * `setViewport`) → zoom out to `minZoom` about the cursor, so the gesture always - * does something rather than stalling. + * `setViewport`, or panned after zooming in and so dropped the memory) → zoom out + * to `zoomOut`, so the gesture always does something rather than stalling. + * + * `zoomOut` is `'min'` by default — `minZoom` about the cursor. `'fit'` puts the + * whole graph back on screen instead, which is what the second double-click means + * on a canvas people read rather than survey; a number is a fixed level, again about + * the cursor. Only this fallback is configurable: with a remembered viewport, going + * back exactly where you came from beats any level anybody could name. + * + * `fit` is a thunk rather than a viewport because measuring every node's bounds is + * only worth doing on the branch that uses it, and most double-clicks zoom in. * * Pure so the decision can be tested without driving d3-zoom through a real DOM. * - * Precondition: `level` must sit strictly above `minZoom`. A toggle needs somewhere - * to go — when the two coincide, "at or above the level" is also "already at the - * floor", so the zoom-out branch has no room and the gesture cannot move the - * viewport. That configuration is rejected at wiring time (createPanZoom keeps - * d3's native stepped handler instead of installing a toggle that would stall); - * should it reach here anyway, the current viewport is returned unchanged rather - * than a transform that merely looks new. + * Precondition: `level` must sit strictly above the level zooming out lands on. A + * toggle needs somewhere to go — when the two coincide, "at or above the level" is + * also "already at the floor", so the zoom-out branch has no room and the gesture + * cannot move the viewport. That configuration is rejected at wiring time + * (createPanZoom keeps d3's native stepped handler instead of installing a toggle + * that would stall); should it reach here anyway, the current viewport is returned + * unchanged rather than a transform that merely looks new. */ export function resolveDblClickZoom( current: ViewportTransform, pointer: { x: number; y: number }, - opts: { level: number; minZoom: number; remembered: ViewportTransform | null }, + opts: { + level: number; + minZoom: number; + remembered: ViewportTransform | null; + zoomOut?: number | 'fit' | 'min'; + fit?: (() => ViewportTransform | null) | null; + }, ): { next: ViewportTransform; remember: ViewportTransform | null } { // Tolerance so floating-point drift at exactly `level` still counts as "at" it. const atOrAboveLevel = current.zoom >= opts.level - 1e-3; @@ -208,13 +241,28 @@ export function resolveDblClickZoom( if (opts.remembered) { return { next: opts.remembered, remember: null }; } - // Degenerate `level <= minZoom`: no headroom below, so zooming out would resolve + + const zoomOut = opts.zoomOut ?? 'min'; + + if (zoomOut === 'fit') { + // A canvas with no measured nodes has no fit to go to; `minZoom` is still a + // move, so fall through to it rather than making the gesture dead on an empty + // canvas. + const fitted = opts.fit?.(); + if (fitted) { + return { next: fitted, remember: null }; + } + } + + const outZoom = Math.max(opts.minZoom, zoomOut === 'fit' || zoomOut === 'min' ? opts.minZoom : zoomOut); + + // Degenerate `level <= outZoom`: no headroom below, so zooming out would resolve // to the identity of `current`. Say so explicitly instead of returning a no-op // dressed up as a transition. - if (current.zoom <= opts.minZoom + 1e-3) { + if (current.zoom <= outZoom + 1e-3) { return { next: current, remember: null }; } - return { next: scaleAbout(current, pointer.x, pointer.y, opts.minZoom), remember: null }; + return { next: scaleAbout(current, pointer.x, pointer.y, outZoom), remember: null }; } export function createPanZoom( @@ -327,6 +375,11 @@ export function createPanZoom( Math.min(maxZoom, options.dblClickZoomLevel ?? DEFAULT_DBLCLICK_ZOOM_LEVEL), ); + const dblClickZoomOutLevel: number | 'fit' | 'min' = + typeof options.dblClickZoomOutLevel === 'number' + ? Math.max(minZoom, Math.min(maxZoom, options.dblClickZoomOutLevel)) + : options.dblClickZoomOutLevel ?? 'min'; + const dblClickHandler = (event: MouseEvent) => { // Deliberately NOT gated on `zoomable`, mirroring createPanZoomFilter, which // blocks wheel and pinch on `zoomable: false` but always lets dblclick through — @@ -347,7 +400,13 @@ export function createPanZoom( const { next, remember } = resolveDblClickZoom( { x: t.x, y: t.y, zoom: t.k }, { x: event.clientX - rect.left, y: event.clientY - rect.top }, - { level: dblClickZoomLevel, minZoom, remembered: rememberedViewport }, + { + level: dblClickZoomLevel, + minZoom, + remembered: rememberedViewport, + zoomOut: dblClickZoomOutLevel, + fit: options.getFitViewport ?? null, + }, ); rememberedViewport = remember; @@ -355,10 +414,14 @@ export function createPanZoom( .call(zoomBehavior.transform, zoomIdentity.translate(next.x, next.y).scale(next.zoom)); }; - // A toggle with `level <= minZoom` has no headroom to zoom back out into, so it - // would stall on the second double-click. Keep d3's stepped handler instead — - // it still zooms in and out — rather than installing a dead gesture. - const toggleAttached = dblClickMode === 'toggle' && dblClickZoomLevel > minZoom + 1e-3; + // A toggle whose level sits at or below the level it zooms back out to has no + // headroom, so it would stall on the second double-click. Keep d3's stepped + // handler instead — it still zooms in and out — rather than installing a dead + // gesture. `'fit'` is measured at gesture time and cannot be checked here, so it + // is held to the same floor as `'min'`; if the graph happens to fit above the + // level, the fallback inside resolveDblClickZoom keeps the gesture honest. + const dblClickZoomOutFloor = typeof dblClickZoomOutLevel === 'number' ? dblClickZoomOutLevel : minZoom; + const toggleAttached = dblClickMode === 'toggle' && dblClickZoomLevel > dblClickZoomOutFloor + 1e-3; if (toggleAttached) { sel.on('dblclick.zoom', null); diff --git a/src/core/types.ts b/src/core/types.ts index 081187c..6afe155 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1834,6 +1834,17 @@ export interface FlowCanvasConfig { * have room to return to. Only consulted when `zoomOnDoubleClick: 'toggle'`. * Default: 1.5 */ dblClickZoomLevel?: number; + /** Where a toggle double-click zooms out to when it has no remembered viewport to + * restore — the user reached this zoom by wheel or `setViewport`, or panned after + * zooming in, which drops the memory. + * + * - `'min'` (default) — `minZoom`, about the cursor. + * - `'fit'` — the whole graph back on screen, the viewport `fitView()` computes. + * - `number` — a fixed level, about the cursor. Clamped to [minZoom, maxZoom]. + * + * A remembered viewport always wins over this. Only consulted when + * `zoomOnDoubleClick: 'toggle'`. Default: `'min'` */ + dblClickZoomOutLevel?: number | 'fit' | 'min'; // ── Select on Drag ──────────────────────────────────────────────── /** Automatically select nodes when they start being dragged. Default: true */ diff --git a/src/plugin/data/flow-canvas.ts b/src/plugin/data/flow-canvas.ts index 4255def..a842813 100644 --- a/src/plugin/data/flow-canvas.ts +++ b/src/plugin/data/flow-canvas.ts @@ -1369,6 +1369,14 @@ export function registerFlowCanvas(Alpine: Alpine) { noWheelClassName: config.noWheelClassName ?? 'nowheel', zoomOnDoubleClick: config.zoomOnDoubleClick, dblClickZoomLevel: config.dblClickZoomLevel, + dblClickZoomOutLevel: config.dblClickZoomOutLevel, + // Only called when a toggle double-click zooms out under + // `dblClickZoomOutLevel: 'fit'`, so the bounds pass costs nothing otherwise. + getFitViewport: (): Viewport | null => { + const bounds = this.getNodesBounds(); + if (bounds.width <= 0 && bounds.height <= 0) return null; + return this.getViewportForBounds(bounds, DEFAULT_FIT_PADDING); + }, panOnDrag: config.panOnDrag, panActivationKeyCode: config.panActivationKeyCode, zoomActivationKeyCode: config.zoomActivationKeyCode,