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