diff --git a/web/static/main.js b/web/static/main.js index 6e78de3..ca4aa9a 100644 --- a/web/static/main.js +++ b/web/static/main.js @@ -271,6 +271,52 @@ function startPan(e) { svg.addEventListener("pointercancel", onUp); } +// Left-click on empty canvas: ambiguous between "deselect" and "pan". +// We resolve by movement — under the drag threshold m gets the historic +// "click empties the selection" behaviour; past the threshold the gesture +// promotes to a pan (Excalidraw / Figma standard). 3px screen-space dead +// zone is enough that a steady click doesn't accidentally nudge the view. +const EMPTY_CANVAS_PAN_THRESHOLD_PX = 3; + +function startEmptyCanvasGesture(e) { + const svg = /** @type {SVGSVGElement} */ ($("#canvas")); + const ctm = svg.getScreenCTM(); + if (!ctm) return; + const scaleX = ctm.a, scaleY = ctm.d; + const startClientX = e.clientX, startClientY = e.clientY; + const startViewX = state.view.x, startViewY = state.view.y; + let panning = false; + try { svg.setPointerCapture(e.pointerId); } catch {} + const onMove = (ev) => { + const dx = ev.clientX - startClientX; + const dy = ev.clientY - startClientY; + if (!panning) { + if (Math.hypot(dx, dy) <= EMPTY_CANVAS_PAN_THRESHOLD_PX) return; + panning = true; + $(".canvas-wrap").classList.add("panning"); + } + state.view.x = startViewX - dx / scaleX; + state.view.y = startViewY - dy / scaleY; + applyViewBox(); + }; + const onUp = (ev) => { + svg.removeEventListener("pointermove", onMove); + svg.removeEventListener("pointerup", onUp); + svg.removeEventListener("pointercancel", onUp); + try { svg.releasePointerCapture(ev.pointerId); } catch {} + if (panning) { + $(".canvas-wrap").classList.remove("panning"); + setViewInURL(); + } else if (state.selection) { + state.selection = null; + render(); + } + }; + svg.addEventListener("pointermove", onMove); + svg.addEventListener("pointerup", onUp); + svg.addEventListener("pointercancel", onUp); +} + function resetView() { state.view.zoom = 1; state.view.x = 0; @@ -2036,12 +2082,18 @@ function onCanvasPointerDown(e) { return; } - // No tool armed: clicks that started on a device/frame/io go to their - // own handlers (drag / select). Leave them alone. - if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id], [data-io-id]")) return; + // No tool armed: clicks that started on a device/frame/io/clamp/port/cable + // go to their own handlers (drag / select / replug). Leave them alone. + if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id], [data-io-id], [data-clamp-id], [data-port-id], [data-cable-id]")) return; - // Plain canvas click = clear selection. - if (state.selection) { state.selection = null; render(); } + // Empty-canvas left-click without an active cable draw: start a + // maybe-pan gesture. It promotes to a pan once the cursor crosses the + // drag threshold; if m clicks without dragging it falls back to the + // historic "clear selection" UX. Other buttons fall through (middle is + // already handled above, right-click is the browser context menu). + if (e.button === 0 && state.cableDrawFromPortID == null) { + startEmptyCanvasGesture(e); + } } function startFrameRubberBand(e, p0) {