fix(ui): +Port feedback + snap dedup + startDrag closure-capture

Three changes from sherlock's Playwright debug (docs/sherlock-+port-bug.md):

1. Select the freshly-placed port. placePortAt now sets
   state.selection = {kind:"port", id:port.id} before render() so the
   inspector switches to the port panel and the .selected halo makes
   the new circle visible — fixes m's "+Port does nothing" perception
   (the port WAS being created server-side; it just rendered invisibly
   stacked under an existing one and the inspector stayed on the device).

2. Snap-to-edge dedup. snapToDeviceEdge now takes the existing ports
   on the device; if the computed (xOff, yOff) lands within 8px of a
   peer on the same edge, slide along the edge in 16px steps until a
   free slot is found. Eliminates pixel-perfect port stacks.

3. startDrag closure-capture. onUp asynchronously referenced
   e.currentTarget after pointerup nulled it, throwing a TypeError
   in the console on every click-only device selection. Capture
   dragTarget in the outer closure and use that inside add/remove.
This commit is contained in:
mAi
2026-05-16 11:12:13 +02:00
parent 90157dfd14
commit 491db730eb

View File

@@ -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;