Compare commits

..

6 Commits

Author SHA1 Message Date
mAi
2933bb8662 fix(ui): left-click-drag on empty canvas pans the view
Canvas zoom shipped pan as middle-drag / Space+drag, which left m unable
to reach a freshly-created frame outside the default viewport — the
only escape was middle-button or holding Space, neither of which is
discoverable.

Empty-canvas left-pointerdown now starts an ambiguous gesture: if the
cursor moves past a 3px screen-space threshold it promotes to a pan
(Excalidraw / Figma standard); below the threshold it falls back to
the historic "click empties the selection" UX so plain clicks still
deselect. Pointerdown on a device, frame, IO marker, port, or cable
keeps routing to its own handler. Middle-drag and Space+drag pan
unchanged.
2026-05-16 14:05:46 +02:00
mAi
98fe040364 merge: v5 — cable routing via clamps (all 6 slices)
picasso shipped on a single branch (6 commits @ 813d59b):
- Migration 007: clamps + cable_clamps with PK(cable_id,ord) +
  UNIQUE(cable_id,clamp_id). Store helpers (CRUD + Attach with
  two-pass shift + Detach gap-close + Reorder).
- HTTP endpoints under /clamps and /cables/:cid/clamps.
- Frontend: +Clamp tool + canvas placement + frame-drag carries
  clamps + clamp inspector with cables-through list and
  cascade-with-confirm delete.
- Polyline cable render through clamps. Mid-segment drag picks
  nearest segment; pointerup snaps to existing clamp within
  MID_SNAP_PX/zoom or creates fresh.
- Bundle viz: shared segments get a thick striped overlay (width
  min(12,2+N), gradient stripes by count desc / id asc).
  ×N badge on clamps with ≥2 cables.
- Export: clamps as 12x12 rounded squares (Excalidraw rectangles);
  cable arrows carry mid-vertices through clamps; bundle viz stays
  viewer-only (Excalidraw can't represent gradient strokes).
2026-05-16 14:04:37 +02:00
mAi
a1de1246e5 merge: remove '+ Type' button from sidebar legend
Per m: cable-type creation lives in the admin modal; the sidebar
button was prominent for a rare action.
2026-05-16 13:52:08 +02:00
mAi
fee9bc5d26 feat(ui): remove '+ Type' button from sidebar legend
Cable type creation is managed via the admin modal (⚙ → Cable types
tab), which makes the prominent sidebar affordance unnecessary. Drop
the button element and its click handler; the legend itself (rows,
edit button per row, active-type selection) is unchanged.
2026-05-16 13:50:49 +02:00
mAi
8df5de193a merge: fix overbroad gitignore matching cmd/mcables/
Bare 'mcables' pattern in .gitignore + .dockerignore matched cmd/mcables/
in addition to the built binary at repo root. Root-anchored to '/mcables'.
cmd/mcables/main.go now tracked in git. Fresh worktrees / clones build
clean without copying main.go from a sibling.
2026-05-16 13:39:16 +02:00
mAi
a675c499c3 fix: root-anchor mcables ignore pattern, commit cmd/mcables/main.go
The bare `mcables` pattern in .gitignore (line 11) and .dockerignore
(line 18) was intended to ignore the built binary at the repo root, but
without a leading slash it also matched the cmd/mcables/ directory. The
result: cmd/mcables/main.go was never tracked in git, and fresh worktrees
had to copy it from a sibling to build.

- Change `mcables` → `/mcables` in both files (still ignores the root
  binary; no longer matches the cmd subdirectory).
- Add cmd/mcables/main.go (copied from picasso's worktree, verified
  identical to head's main checkout).

Verified: `git check-ignore cmd/mcables/main.go` returns not-ignored;
a touched `./mcables` at the repo root is still ignored via `/mcables`.
`go build ./...` clean.
2026-05-16 13:38:52 +02:00
5 changed files with 123 additions and 9 deletions

View File

@@ -15,7 +15,7 @@ data
# Build artefacts
bin
mcables
/mcables
# Editor cruft
.vscode

2
.gitignore vendored
View File

@@ -8,7 +8,7 @@ data/*.db-shm
# Build artefacts
bin/
mcables
/mcables
# Editor
.vscode/

64
cmd/mcables/main.go Normal file
View File

@@ -0,0 +1,64 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"mgit.msbls.de/m/mcables/internal/db"
"mgit.msbls.de/m/mcables/internal/server"
"mgit.msbls.de/m/mcables/web"
)
func main() {
addr := envOr("MCABLES_ADDR", "0.0.0.0:7777")
dbPath := envOr("MCABLES_DB", "./data/mcables.db")
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
log.Fatalf("mkdir data dir: %v", err)
}
store, err := db.Open(dbPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer store.Close()
if err := db.Migrate(store.DB()); err != nil {
log.Fatalf("migrate: %v", err)
}
srv := &http.Server{
Addr: addr,
Handler: server.New(store, web.Static()),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
log.Printf("mcables listening on %s (db=%s)", addr, dbPath)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Printf("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@@ -36,7 +36,6 @@
<section class="legend">
<h2 class="sidebar-heading">Cable types</h2>
<ul id="legend-list" class="legend-list"></ul>
<button type="button" id="btn-add-type" class="btn btn-tiny">+ Type</button>
</section>
<section class="tools">
<h2 class="sidebar-heading">Tools</h2>

View File

@@ -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) {
@@ -3709,7 +3761,6 @@ async function boot() {
bindCloseButtons($("#modal-admin"));
$("#btn-new-project").addEventListener("click", openNewProjectModal);
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
$("#btn-delete-project").addEventListener("click", openDeleteProjectModal);
$("#btn-admin").addEventListener("click", openAdminModal);
$("#btn-solve").addEventListener("click", openSolveModal);