diff --git a/web/static/main.js b/web/static/main.js index 353c796..34563d2 100644 --- a/web/static/main.js +++ b/web/static/main.js @@ -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 = `

Nothing selected.

`; } } @@ -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 = ` +

Port

+
+
device
${dev.name}
+
type
+
${ctName}
+
+ + +
+ +
+ `; + 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) { diff --git a/web/static/style.css b/web/static/style.css index 85af292..d30ca38 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -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; }