merge: port UX bundle — selection feedback + even-spacing + onUp + device colour
3 commits (491db73,b28fc0c,86264d1): - +Port now sets state.selection on the new port → inspector switches to the port panel + halo shows - Ports relayout to even spacing along the affected edge on every add/delete/edge-change (no more invisible stacking) - startDrag.onUp captures the rect in closure instead of reading currentTarget after pointerup (no more 'classList of null' spam) - Device colour: dropped CSS stroke/fill hard-codes, inline style now paints the rect — picker actually changes the visible colour All verified end-to-end on the deployed image.
This commit is contained in:
@@ -293,10 +293,14 @@ function renderCanvas() {
|
|||||||
|
|
||||||
for (const d of state.devices) {
|
for (const d of state.devices) {
|
||||||
const g = svgEl("g", { "data-device-id": d.id });
|
const g = svgEl("g", { "data-device-id": d.id });
|
||||||
|
// Stroke = the user-picked colour; fill = a 12% tint of it via
|
||||||
|
// color-mix so the device "reads" coloured without becoming garish.
|
||||||
|
// Inline style beats the .device-rect class CSS, which is why CSS
|
||||||
|
// no longer hard-codes stroke/fill on that class.
|
||||||
const rect = svgEl("rect", {
|
const rect = svgEl("rect", {
|
||||||
x: d.x, y: d.y, width: d.width, height: d.height,
|
x: d.x, y: d.y, width: d.width, height: d.height,
|
||||||
class: "device-rect svg-draggable",
|
class: "device-rect svg-draggable",
|
||||||
stroke: d.color,
|
style: `stroke: ${d.color}; fill: color-mix(in srgb, ${d.color} 12%, white);`,
|
||||||
rx: 3, ry: 3,
|
rx: 3, ry: 3,
|
||||||
});
|
});
|
||||||
if (state.selection?.kind === "device" && state.selection.id === d.id) {
|
if (state.selection?.kind === "device" && state.selection.id === d.id) {
|
||||||
@@ -1008,7 +1012,7 @@ function renderInspectorPort(body, id) {
|
|||||||
const ct = state.cableTypes.find((t) => t.id === prt.type_id);
|
const ct = state.cableTypes.find((t) => t.id === prt.type_id);
|
||||||
const ctColor = ct?.color || "#888";
|
const ctColor = ct?.color || "#888";
|
||||||
const ctName = ct?.name || "?";
|
const ctName = ct?.name || "?";
|
||||||
const currentEdge = edgeOf(dev, prt);
|
const currentEdge = portEdge(prt, dev);
|
||||||
|
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<p class="section-title">Port</p>
|
<p class="section-title">Port</p>
|
||||||
@@ -1046,13 +1050,26 @@ function renderInspectorPort(body, id) {
|
|||||||
|
|
||||||
body.querySelector("#port-edge").addEventListener("change", async (e) => {
|
body.querySelector("#port-edge").addEventListener("change", async (e) => {
|
||||||
if (!state.active) return;
|
if (!state.active) return;
|
||||||
const edge = /** @type {HTMLSelectElement} */ (e.target).value;
|
const newEdge = /** @type {HTMLSelectElement} */ (e.target).value;
|
||||||
const { xOff, yOff } = edgeCenter(dev, edge);
|
const oldEdge = portEdge(prt, dev);
|
||||||
|
if (newEdge === oldEdge) return;
|
||||||
|
// PATCH to a temp position on the new edge so portEdge() classifies
|
||||||
|
// this port onto newEdge in the upcoming relayouts. The temp position
|
||||||
|
// gets overwritten by relayoutEdge(newEdge); the only thing that
|
||||||
|
// matters is that the port is unambiguously on the right edge.
|
||||||
|
const tmp = edgeCentre(dev, newEdge);
|
||||||
try {
|
try {
|
||||||
const updated = await patchPort(state.active.id, prt.id, {
|
const updated = await patchPort(state.active.id, prt.id, {
|
||||||
x_offset: xOff, y_offset: yOff,
|
x_offset: tmp.xOff, y_offset: tmp.yOff,
|
||||||
});
|
});
|
||||||
Object.assign(prt, updated);
|
Object.assign(prt, updated);
|
||||||
|
// Re-space both affected edges: the one the port left and the one
|
||||||
|
// it landed on. Order doesn't matter — they operate on disjoint
|
||||||
|
// port sets.
|
||||||
|
await Promise.all([
|
||||||
|
relayoutEdge(dev.id, oldEdge),
|
||||||
|
relayoutEdge(dev.id, newEdge),
|
||||||
|
]);
|
||||||
renderCanvas();
|
renderCanvas();
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
alert(`Move port failed: ${ex.message}`);
|
alert(`Move port failed: ${ex.message}`);
|
||||||
@@ -1062,11 +1079,15 @@ function renderInspectorPort(body, id) {
|
|||||||
body.querySelector("#port-delete").addEventListener("click", async () => {
|
body.querySelector("#port-delete").addEventListener("click", async () => {
|
||||||
if (!state.active) return;
|
if (!state.active) return;
|
||||||
if (!confirm("Delete this port?")) return;
|
if (!confirm("Delete this port?")) return;
|
||||||
|
const wasEdge = portEdge(prt, dev);
|
||||||
try {
|
try {
|
||||||
await deletePort(state.active.id, prt.id);
|
await deletePort(state.active.id, prt.id);
|
||||||
state.ports = state.ports.filter((p) => p.id !== prt.id);
|
state.ports = state.ports.filter((p) => p.id !== prt.id);
|
||||||
const snap = await getSnapshot(state.active.id);
|
const snap = await getSnapshot(state.active.id);
|
||||||
state.cables = snap.cables || [];
|
state.cables = snap.cables || [];
|
||||||
|
// Re-space the edge the deleted port was on so the survivors
|
||||||
|
// shift back to even spacing.
|
||||||
|
await relayoutEdge(dev.id, wasEdge);
|
||||||
state.selection = null;
|
state.selection = null;
|
||||||
render();
|
render();
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
@@ -1075,19 +1096,11 @@ function renderInspectorPort(body, id) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Which edge does a port currently sit on? Matches the convention in
|
|
||||||
// snapToDeviceEdge: x_offset = 0 → left, = width → right, y_offset = 0
|
|
||||||
// → top, otherwise bottom (the default).
|
|
||||||
function edgeOf(dev, prt) {
|
|
||||||
if (prt.x_offset <= 0) return "left";
|
|
||||||
if (prt.x_offset >= dev.width) return "right";
|
|
||||||
if (prt.y_offset <= 0) return "top";
|
|
||||||
return "bottom";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Centre of the named edge, expressed as (x_offset, y_offset) relative
|
// Centre of the named edge, expressed as (x_offset, y_offset) relative
|
||||||
// to the device origin.
|
// to the device origin. Used as a temp anchor when moving a port between
|
||||||
function edgeCenter(dev, edge) {
|
// edges — the precise centre value is immediately overwritten by
|
||||||
|
// relayoutEdge, but it has to land on the right edge.
|
||||||
|
function edgeCentre(dev, edge) {
|
||||||
switch (edge) {
|
switch (edge) {
|
||||||
case "top": return { xOff: dev.width / 2, yOff: 0 };
|
case "top": return { xOff: dev.width / 2, yOff: 0 };
|
||||||
case "right": return { xOff: dev.width, yOff: dev.height / 2 };
|
case "right": return { xOff: dev.width, yOff: dev.height / 2 };
|
||||||
@@ -1567,12 +1580,79 @@ function snapToDeviceEdge(device, x, y) {
|
|||||||
// Clamp the perpendicular coordinate so the port sits *on* the rect.
|
// Clamp the perpendicular coordinate so the port sits *on* the rect.
|
||||||
const localX = Math.max(0, Math.min(device.width, x - device.x));
|
const localX = Math.max(0, Math.min(device.width, x - device.x));
|
||||||
const localY = Math.max(0, Math.min(device.height, y - device.y));
|
const localY = Math.max(0, Math.min(device.height, y - device.y));
|
||||||
if (min === dxLeft) return { xOff: 0, yOff: localY, edge: "left" };
|
if (min === dxLeft) return { xOff: 0, yOff: localY, edge: "left" };
|
||||||
if (min === dxRight) return { xOff: device.width, yOff: localY, edge: "right" };
|
if (min === dxRight) return { xOff: device.width, yOff: localY, edge: "right" };
|
||||||
if (min === dyTop) return { xOff: localX, yOff: 0, edge: "top" };
|
if (min === dyTop) return { xOff: localX, yOff: 0, edge: "top" };
|
||||||
return { xOff: localX, yOff: device.height, edge: "bottom" };
|
return { xOff: localX, yOff: device.height, edge: "bottom" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which edge does a given port currently sit on? Snaps the port's
|
||||||
|
// existing (x_offset, y_offset) to the nearest of the four edges using
|
||||||
|
// the same distance heuristic as snapToDeviceEdge.
|
||||||
|
function portEdge(port, device) {
|
||||||
|
const dL = port.x_offset;
|
||||||
|
const dR = device.width - port.x_offset;
|
||||||
|
const dT = port.y_offset;
|
||||||
|
const dB = device.height - port.y_offset;
|
||||||
|
const min = Math.min(dL, dR, dT, dB);
|
||||||
|
if (min === dL) return "left";
|
||||||
|
if (min === dR) return "right";
|
||||||
|
if (min === dT) return "top";
|
||||||
|
return "bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Even-spacing layout invariant for ports on a device edge: m wants
|
||||||
|
// every port lined up on its edge with no overlap. After any change
|
||||||
|
// to the set of ports on an edge (add / move / delete), recompute the
|
||||||
|
// offsets so that for N ports they sit at relative positions
|
||||||
|
// i/(N+1) along the edge for i=1..N.
|
||||||
|
//
|
||||||
|
// Sort key preserves m's intent: top/bottom by current x_offset
|
||||||
|
// (left→right), left/right by current y_offset (top→bottom). For a
|
||||||
|
// freshly-placed port, that's the click position projected onto the
|
||||||
|
// edge, so the port keeps its "I dropped it roughly here" rank.
|
||||||
|
//
|
||||||
|
// PATCHes only the ports whose offsets actually change, and updates
|
||||||
|
// state.ports in place. Returns once every PATCH resolves.
|
||||||
|
async function relayoutEdge(deviceID, edge) {
|
||||||
|
if (!state.active) return;
|
||||||
|
const dev = state.devices.find((d) => d.id === deviceID);
|
||||||
|
if (!dev) return;
|
||||||
|
const isHorizontal = edge === "top" || edge === "bottom";
|
||||||
|
const axis = isHorizontal ? dev.width : dev.height;
|
||||||
|
const peers = state.ports
|
||||||
|
.filter((p) => p.device_id === deviceID && portEdge(p, dev) === edge)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) =>
|
||||||
|
isHorizontal ? a.x_offset - b.x_offset : a.y_offset - b.y_offset);
|
||||||
|
const n = peers.length;
|
||||||
|
if (n === 0) return;
|
||||||
|
const patches = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const parallel = axis * (i + 1) / (n + 1);
|
||||||
|
let xOff, yOff;
|
||||||
|
switch (edge) {
|
||||||
|
case "top": xOff = parallel; yOff = 0; break;
|
||||||
|
case "bottom": xOff = parallel; yOff = dev.height; break;
|
||||||
|
case "left": xOff = 0; yOff = parallel; break;
|
||||||
|
case "right": xOff = dev.width; yOff = parallel; break;
|
||||||
|
}
|
||||||
|
const p = peers[i];
|
||||||
|
if (p.x_offset === xOff && p.y_offset === yOff) continue;
|
||||||
|
p.x_offset = xOff;
|
||||||
|
p.y_offset = yOff;
|
||||||
|
patches.push(patchPort(state.active.id, p.id, { x_offset: xOff, y_offset: yOff })
|
||||||
|
.then((updated) => Object.assign(p, updated)));
|
||||||
|
}
|
||||||
|
if (patches.length) {
|
||||||
|
try {
|
||||||
|
await Promise.all(patches);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Re-layout failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Port-click flow:
|
/** Port-click flow:
|
||||||
* - A cable draw is in progress (cableDrawFromPortID set):
|
* - A cable draw is in progress (cableDrawFromPortID set):
|
||||||
* same port → cancel; another port → finish the cable.
|
* same port → cancel; another port → finish the cable.
|
||||||
@@ -1697,6 +1777,13 @@ async function placePortAt(p) {
|
|||||||
y_offset: snap.yOff,
|
y_offset: snap.yOff,
|
||||||
});
|
});
|
||||||
state.ports.push(port);
|
state.ports.push(port);
|
||||||
|
// Re-layout all ports on this edge so the new one + existing ones
|
||||||
|
// are evenly spaced — m's invariant: never let two ports stack.
|
||||||
|
await relayoutEdge(did, snap.edge);
|
||||||
|
// Select the freshly-placed port so the inspector switches to the
|
||||||
|
// port panel (edge dropdown / label / delete) and the .selected halo
|
||||||
|
// marks it.
|
||||||
|
state.selection = { kind: "port", id: port.id };
|
||||||
armTool(null);
|
armTool(null);
|
||||||
render();
|
render();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1821,7 +1908,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);
|
svg.setPointerCapture(e.pointerId);
|
||||||
|
|
||||||
let dragged = false;
|
let dragged = false;
|
||||||
@@ -1843,7 +1936,7 @@ function startDrag(e, kind, id) {
|
|||||||
svg.removeEventListener("pointermove", onMove);
|
svg.removeEventListener("pointermove", onMove);
|
||||||
svg.removeEventListener("pointerup", onUp);
|
svg.removeEventListener("pointerup", onUp);
|
||||||
svg.releasePointerCapture(e.pointerId);
|
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 (!dragged) { render(); return; } // click only — re-render to apply selection halo
|
||||||
if (!state.active) return;
|
if (!state.active) return;
|
||||||
|
|||||||
@@ -183,9 +183,10 @@ body {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stroke + fill come from the device's user-set colour, written as
|
||||||
|
inline style in renderCanvas — leaving them out of .device-rect so
|
||||||
|
the author CSS doesn't override the inline style. */
|
||||||
.device-rect {
|
.device-rect {
|
||||||
fill: #fff;
|
|
||||||
stroke: var(--text);
|
|
||||||
stroke-width: 1.5;
|
stroke-width: 1.5;
|
||||||
}
|
}
|
||||||
.device-rect.selected { stroke-width: 3; }
|
.device-rect.selected { stroke-width: 3; }
|
||||||
|
|||||||
Reference in New Issue
Block a user