merge: port UX — coloured fill + selectable + edge picker

picasso shipped (1 commit @ 82cf5a3, +157/-28):
- onPortPointerDown rewritten into 4 deterministic branches:
  cable-draw-in-progress | no-tool-no-draw | cable-tool | other-tools
  (bubble). Other-tools branch is what makes +Port placement work
  when the click lands on an existing port — the previous handler
  silently returned for any non-cable tool.
- Port circles fill + stroke in cable-type colour. .selected halo.
- New renderInspectorPort: type swatch + label + edge dropdown
  (Top/Right/Bottom/Left) + delete. Edge change PATCHes x_offset
  and y_offset to the chosen side's centre.

End-to-end verified on deployed image via PATCH /ports/:id round-trip.
This commit is contained in:
mAi
2026-05-16 02:21:09 +02:00
2 changed files with 159 additions and 30 deletions

View File

@@ -63,7 +63,7 @@ const state = {
portToolTypeID: /** @type {number|null} */ (null),
/** Slice-7: when the user clicked a source port, this is its id. */
cableDrawFromPortID: /** @type {number|null} */ (null),
/** @type {{kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle", id: number} | null} */ selection: null,
/** @type {{kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port", id: number} | null} */ selection: null,
};
// ---------- API client ---------- //
@@ -310,21 +310,26 @@ function renderCanvas() {
g.append(rect, label);
// Render ports as small circles at (device.x + x_offset, device.y + y_offset).
// Stroke colour = the cable_type colour the port carries; fill stays white
// so the port reads against any device colour.
// Both fill and stroke = cable_type colour so the port is obviously coloured
// against the device rect.
const ports = portsByDevice.get(d.id) || [];
for (const prt of ports) {
const cx = d.x + prt.x_offset;
const cy = d.y + prt.y_offset;
const color = cableTypeColor.get(prt.type_id) || "#888";
const cls = "port-circle" + (state.cableDrawFromPortID === prt.id ? " cable-from" : "");
const isCableFrom = state.cableDrawFromPortID === prt.id;
const isSelected = state.selection?.kind === "port" && state.selection.id === prt.id;
const cls = "port-circle"
+ (isCableFrom ? " cable-from" : "")
+ (isSelected ? " selected" : "");
const c = svgEl("circle", {
cx, cy, r: 5,
class: cls,
fill: color,
stroke: color,
"data-port-id": prt.id,
});
// Slice 7: port-click drives the manual cable-draw flow.
// Port-click drives both cable-draw (slice 7) and port-select (this fix).
c.addEventListener("pointerdown", (e) => onPortPointerDown(e, prt));
g.append(c);
}
@@ -431,6 +436,7 @@ function renderInspector() {
case "cable_type": return renderInspectorCableType(body, state.selection.id);
case "requirement": return renderInspectorRequirement(body, state.selection.id);
case "cable": return renderInspectorCable(body, state.selection.id);
case "port": return renderInspectorPort(body, state.selection.id);
default: body.innerHTML = `<p class="muted">Nothing selected.</p>`;
}
}
@@ -993,6 +999,104 @@ function renderInspectorIO(body, id) {
});
}
// Slice 7 follow-up: m can select a port to edit its edge / label / delete.
function renderInspectorPort(body, id) {
const prt = state.ports.find((p) => p.id === id);
if (!prt) { body.innerHTML = ""; return; }
const dev = state.devices.find((d) => d.id === prt.device_id);
if (!dev) { body.innerHTML = ""; return; }
const ct = state.cableTypes.find((t) => t.id === prt.type_id);
const ctColor = ct?.color || "#888";
const ctName = ct?.name || "?";
const currentEdge = edgeOf(dev, prt);
body.innerHTML = `
<p class="section-title">Port</p>
<dl>
<dt>device</dt><dd>${dev.name}</dd>
<dt>type</dt>
<dd><span class="swatch" style="background:${ctColor}"></span>${ctName}</dd>
</dl>
<label class="field">
<span>Label</span>
<input class="inline-input" id="port-label" value="" />
</label>
<label class="field">
<span>Edge</span>
<select id="port-edge">
<option value="top">Top</option>
<option value="right">Right</option>
<option value="bottom">Bottom</option>
<option value="left">Left</option>
</select>
</label>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="port-delete">Delete</button>
</div>
`;
body.querySelector("#port-label").value = prt.label ?? "";
body.querySelector("#port-edge").value = currentEdge;
bindDebouncedRename(body.querySelector("#port-label"), async (label) => {
if (!state.active) return;
const updated = await patchPort(state.active.id, prt.id, { label });
Object.assign(prt, updated);
renderCanvas();
});
body.querySelector("#port-edge").addEventListener("change", async (e) => {
if (!state.active) return;
const edge = /** @type {HTMLSelectElement} */ (e.target).value;
const { xOff, yOff } = edgeCenter(dev, edge);
try {
const updated = await patchPort(state.active.id, prt.id, {
x_offset: xOff, y_offset: yOff,
});
Object.assign(prt, updated);
renderCanvas();
} catch (ex) {
alert(`Move port failed: ${ex.message}`);
}
});
body.querySelector("#port-delete").addEventListener("click", async () => {
if (!state.active) return;
if (!confirm("Delete this port?")) return;
try {
await deletePort(state.active.id, prt.id);
state.ports = state.ports.filter((p) => p.id !== prt.id);
const snap = await getSnapshot(state.active.id);
state.cables = snap.cables || [];
state.selection = null;
render();
} catch (ex) {
alert(`Delete failed: ${ex.message}`);
}
});
}
// 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
// to the device origin.
function edgeCenter(dev, edge) {
switch (edge) {
case "top": return { xOff: dev.width / 2, yOff: 0 };
case "right": return { xOff: dev.width, yOff: dev.height / 2 };
case "bottom": return { xOff: dev.width / 2, yOff: dev.height };
case "left": return { xOff: 0, yOff: dev.height / 2 };
default: return { xOff: dev.width / 2, yOff: dev.height };
}
}
function renderInspectorCableType(body, id) {
const t = state.cableTypes.find((x) => x.id === id);
if (!t) { body.innerHTML = ""; return; }
@@ -1470,30 +1574,50 @@ function snapToDeviceEdge(device, x, y) {
}
/** Port-click flow:
* 1) No source picked yet → this port becomes the source. Highlight it.
* 2) Source already picked → this port is the target. POST a cable
* with `from_port_id` / `to_port_id`, type from the source port,
* auto=0. Shift-click flips the target to "bind to whole device"
* (uses `to_device_id` instead). */
* - A cable draw is in progress (cableDrawFromPortID set):
* same port → cancel; another port → finish the cable.
* - Otherwise, no tool armed:
* select the port (inspector shows edge picker + label + delete).
* - Otherwise, any non-cable tool armed:
* bubble so the canvas-level tool handler runs (lets +Port place
* a new port even when the click lands on an existing one). */
function onPortPointerDown(e, port) {
if (!state.active) return;
if (state.tool && state.tool !== "cable") return; // other tool wins
e.stopPropagation();
e.preventDefault();
if (state.cableDrawFromPortID == null) {
// Cable-draw flow takes precedence whenever a source is already picked.
if (state.cableDrawFromPortID != null) {
e.stopPropagation();
e.preventDefault();
if (state.cableDrawFromPortID === port.id) {
state.cableDrawFromPortID = null;
armTool(null);
render();
return;
}
finishCableDrawAt(port, e.shiftKey);
return;
}
// No cable in progress, no tool: select the port → inspector pane.
if (!state.tool) {
e.stopPropagation();
e.preventDefault();
state.selection = { kind: "port", id: port.id };
render();
return;
}
// The cable tool: start a draw from this port.
if (state.tool === "cable") {
e.stopPropagation();
e.preventDefault();
state.cableDrawFromPortID = port.id;
armTool("cable"); // get the crosshair cursor + visual cue
render();
return;
}
if (state.cableDrawFromPortID === port.id) {
// Cancel — clicked the same port again.
state.cableDrawFromPortID = null;
armTool(null);
render();
return;
}
finishCableDrawAt(port, e.shiftKey);
// Any other tool (port / frame / device / io / req): let the click
// bubble up so the canvas-level branch fires.
}
async function finishCableDrawAt(targetPort, shiftKey) {

View File

@@ -277,16 +277,17 @@ body {
user-select: none;
}
/* Ports — small circles laid out along the device edge. The fill is
white so the port is visible regardless of the underlying device's
stroke; the stroke colour comes from the cable_type the port carries
(set inline in JS). */
/* Ports — small circles laid out along the device edge. Both fill and
stroke come from the cable_type the port carries (set inline in JS)
so the port reads clearly as a coloured anchor on the device. */
.port-circle {
fill: #fff;
stroke: var(--text);
stroke-width: 2;
cursor: crosshair;
}
.port-circle.selected {
stroke-width: 3;
filter: drop-shadow(0 0 4px var(--accent));
}
.port-row {
display: grid;
@@ -296,11 +297,15 @@ body {
font-size: 12px;
padding: 2px 0;
}
.port-row .swatch {
.port-row .swatch,
.swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.15);
margin-right: 6px;
vertical-align: middle;
}
.port-row .label { color: var(--text); }
.port-row .conn { color: var(--text-muted); font-size: 11px; }