diff --git a/web/static/main.js b/web/static/main.js index 34563d2..f503f6d 100644 --- a/web/static/main.js +++ b/web/static/main.js @@ -1557,7 +1557,7 @@ function openNewDeviceModal(geom) { /** Snap (x, y) onto the closest edge of `device`. Returns the (x_off, * y_off) relative to the device's top-left + a debug-friendly edge name. */ -function snapToDeviceEdge(device, x, y) { +function snapToDeviceEdge(device, x, y, existingPorts) { // Distance from the point to each of the four edges. const dxLeft = Math.abs(x - device.x); const dxRight = Math.abs((device.x + device.width) - x); @@ -1567,10 +1567,49 @@ function snapToDeviceEdge(device, x, y) { // Clamp the perpendicular coordinate so the port sits *on* the rect. const localX = Math.max(0, Math.min(device.width, x - device.x)); const localY = Math.max(0, Math.min(device.height, y - device.y)); - if (min === dxLeft) return { xOff: 0, yOff: localY, edge: "left" }; - if (min === dxRight) return { xOff: device.width, yOff: localY, edge: "right" }; - if (min === dyTop) return { xOff: localX, yOff: 0, edge: "top" }; - return { xOff: localX, yOff: device.height, edge: "bottom" }; + let snap; + if (min === dxLeft) snap = { xOff: 0, yOff: localY, edge: "left" }; + else if (min === dxRight) snap = { xOff: device.width, yOff: localY, edge: "right" }; + else if (min === dyTop) snap = { xOff: localX, yOff: 0, edge: "top" }; + else snap = { xOff: localX, yOff: device.height, edge: "bottom" }; + return resolveCollision(snap, device, existingPorts); +} + +// If the snap lands within 8px of another port on the same edge, slide +// along the edge in 16px steps (alternating directions, expanding) until +// the slot is clear or we run out of room. Prevents pixel-perfect stacks +// when m drops two ports near the same midpoint — sherlock's secondary +// fix for the invisible-stacking problem in +Port. +function resolveCollision(snap, device, existingPorts) { + if (!existingPorts || !existingPorts.length) return snap; + const onSameEdge = (port) => { + switch (snap.edge) { + case "left": return port.x_offset === 0; + case "right": return port.x_offset === device.width; + case "top": return port.y_offset === 0; + case "bottom": return port.y_offset === device.height; + } + return false; + }; + const isHorizontal = snap.edge === "top" || snap.edge === "bottom"; + const axisMax = isHorizontal ? device.width : device.height; + const peers = existingPorts.filter(onSameEdge).map((p) => isHorizontal ? p.x_offset : p.y_offset); + if (!peers.length) return snap; + const step = 16, tol = 8; + let pos = isHorizontal ? snap.xOff : snap.yOff; + const clear = (v) => peers.every((q) => Math.abs(v - q) >= tol); + if (clear(pos)) return snap; + // Try increasing offsets in both directions until we find a free slot. + for (let i = 1; i * step <= axisMax; i++) { + const up = pos + i * step; + const down = pos - i * step; + if (up <= axisMax && clear(up)) { pos = up; break; } + if (down >= 0 && clear(down)) { pos = down; break; } + } + pos = Math.max(0, Math.min(axisMax, pos)); + return isHorizontal + ? { xOff: pos, yOff: snap.yOff, edge: snap.edge } + : { xOff: snap.xOff, yOff: pos, edge: snap.edge }; } /** Port-click flow: @@ -1689,7 +1728,8 @@ async function placePortAt(p) { if (did == null || tid == null) { armTool(null); return; } const dev = state.devices.find((d) => d.id === did); if (!dev) { armTool(null); return; } - const snap = snapToDeviceEdge(dev, p.x, p.y); + const sibs = state.ports.filter((p) => p.device_id === did); + const snap = snapToDeviceEdge(dev, p.x, p.y, sibs); try { const port = await createPort(state.active.id, did, { type_id: tid, @@ -1697,6 +1737,11 @@ async function placePortAt(p) { y_offset: snap.yOff, }); state.ports.push(port); + // Select the freshly-placed port so the inspector switches to the + // port panel (edge dropdown / label / delete) and the .selected halo + // makes the new circle visually obvious — sherlock's primary fix for + // the "+Port feels dead" perception bug. + state.selection = { kind: "port", id: port.id }; armTool(null); render(); } catch (e) { @@ -1821,7 +1866,13 @@ function startDrag(e, kind, id) { } } - e.currentTarget.classList.add("dragging"); + // Capture the rect element NOW: by the time onUp fires async, the + // browser has nulled out e.currentTarget on the pointerdown event, + // so `e.currentTarget.classList.remove("dragging")` would throw + // "Cannot read properties of null". Sherlock surfaced this from the + // click-only path that pageerror-spammed every device click. + const dragTarget = /** @type {Element} */ (e.currentTarget); + dragTarget.classList.add("dragging"); svg.setPointerCapture(e.pointerId); let dragged = false; @@ -1843,7 +1894,7 @@ function startDrag(e, kind, id) { svg.removeEventListener("pointermove", onMove); svg.removeEventListener("pointerup", onUp); svg.releasePointerCapture(e.pointerId); - e.currentTarget.classList.remove("dragging"); + dragTarget.classList.remove("dragging"); if (!dragged) { render(); return; } // click only — re-render to apply selection halo if (!state.active) return;