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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user