diff --git a/web/static/main.js b/web/static/main.js
index 6e6c52b..70080e7 100644
--- a/web/static/main.js
+++ b/web/static/main.js
@@ -56,14 +56,11 @@ const state = {
/** @type {Bundle[]} */ bundles: [],
/** @type {SetupTemplate[]} */ setupTemplates: [],
activeTypeId: /** @type {number|null} */ (null),
- /** "frame" | "device" | "io" | "req" | "port" | "cable" | null */
+ /** "frame" | "device" | "io" | "req" | "cable" | null */
tool: /** @type {string|null} */ (null),
- /** Slice-7 transient state for the +Port tool. */
- portToolDevice: /** @type {number|null} */ (null),
- 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"|"port", id: number} | null} */ selection: null,
+ /** @type {({kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port", id: number} | {kind: "port_new", device_id: number}) | null} */ selection: null,
};
// ---------- API client ---------- //
@@ -441,6 +438,7 @@ function renderInspector() {
case "requirement": return renderInspectorRequirement(body, state.selection.id);
case "cable": return renderInspectorCable(body, state.selection.id);
case "port": return renderInspectorPort(body, state.selection.id);
+ case "port_new": return renderInspectorPortNew(body, state.selection.device_id);
default: body.innerHTML = `
Nothing selected.
`;
}
}
@@ -727,13 +725,24 @@ function renderInspectorDevice(body, id) {
});
});
- // +Port — arms the port-placement gesture. Active cable type comes
- // from the legend selection; if none, defaults to the first cable_type.
+ // +Port — switch the inspector to the new-port form. m fills in
+ // type + edge + label and clicks Create; no canvas click required.
body.querySelector("#dev-add-port").addEventListener("click", () => {
if (!state.active) return;
- const typeID = state.activeTypeId ?? state.cableTypes[0]?.id;
- if (!typeID) { alert("Pick a cable type in the legend first"); return; }
- armPortTool(d.id, typeID);
+ state.selection = { kind: "port_new", device_id: d.id };
+ render();
+ });
+
+ // Clicking a port row in the device's port list selects that port
+ // and opens its editor in the inspector pane.
+ body.querySelectorAll(".port-row[data-port-id]").forEach((row) => {
+ row.addEventListener("click", (e) => {
+ if (e.target instanceof HTMLElement && e.target.closest(".port-del")) return;
+ const pid = Number(row.getAttribute("data-port-id"));
+ if (!pid) return;
+ state.selection = { kind: "port", id: pid };
+ render();
+ });
});
// Per-port delete.
@@ -1003,27 +1012,26 @@ function renderInspectorIO(body, id) {
});
}
-// Slice 7 follow-up: m can select a port to edit its edge / label / delete.
+// Port editor — type / edge / label / delete. m can also navigate back
+// to the device by clicking "back to device" or anywhere on the device.
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 = portEdge(prt, dev);
+ const typeOptions = state.cableTypes
+ .map((t) => `${escapeHtml(t.name)} `)
+ .join("");
body.innerHTML = `
Port
-
- device ${dev.name}
- type
- ${ctName}
-
+
+ ← ${escapeHtml(dev.name)}
+
- Label
-
+ Type
+ ${typeOptions}
Edge
@@ -1034,12 +1042,36 @@ function renderInspectorPort(body, id) {
Left
+
+ Label
+
+
Delete
`;
- body.querySelector("#port-label").value = prt.label ?? "";
+ body.querySelector("#port-type").value = String(prt.type_id);
body.querySelector("#port-edge").value = currentEdge;
+ body.querySelector("#port-label").value = prt.label ?? "";
+
+ body.querySelector("#port-back-device").addEventListener("click", (e) => {
+ e.preventDefault();
+ state.selection = { kind: "device", id: dev.id };
+ render();
+ });
+
+ body.querySelector("#port-type").addEventListener("change", async (e) => {
+ if (!state.active) return;
+ const newTypeID = Number(/** @type {HTMLSelectElement} */ (e.target).value);
+ if (newTypeID === prt.type_id) return;
+ try {
+ const updated = await patchPort(state.active.id, prt.id, { type_id: newTypeID });
+ Object.assign(prt, updated);
+ renderCanvas();
+ } catch (ex) {
+ alert(`Type change failed: ${ex.message}`);
+ }
+ });
bindDebouncedRename(body.querySelector("#port-label"), async (label) => {
if (!state.active) return;
@@ -1110,6 +1142,114 @@ function edgeCentre(dev, edge) {
}
}
+// Compute the next available default label for a new port of `typeID`
+// on `deviceID`. e.g. if a TV already has "HDMI 1" and "HDMI 2", a new
+// HDMI port gets "HDMI 3".
+function nextDefaultPortLabel(deviceID, typeID) {
+ const ct = state.cableTypes.find((t) => t.id === typeID);
+ const prefix = ct?.name || "Port";
+ const sibs = state.ports.filter((p) => p.device_id === deviceID && p.type_id === typeID);
+ let max = 0;
+ for (const p of sibs) {
+ const m = (p.label || "").match(new RegExp("^" + escapeRegExp(prefix) + "\\s+(\\d+)$"));
+ if (m) {
+ const n = parseInt(m[1], 10);
+ if (n > max) max = n;
+ }
+ }
+ return `${prefix} ${Math.max(max + 1, sibs.length + 1)}`;
+}
+
+function escapeRegExp(s) {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+// "Add port" form. Submit → POST → switch inspector to the new port's
+// editor. m can cancel back to the device inspector.
+function renderInspectorPortNew(body, deviceID) {
+ const dev = state.devices.find((d) => d.id === deviceID);
+ if (!dev) { body.innerHTML = ""; return; }
+ if (state.cableTypes.length === 0) {
+ body.innerHTML = `
+ Add port
+ No cable types defined. Add one from the legend first.
+
+ Cancel
+
`;
+ body.querySelector("#port-new-cancel").addEventListener("click", () => {
+ state.selection = { kind: "device", id: dev.id };
+ render();
+ });
+ return;
+ }
+ const defaultTypeID = state.activeTypeId ?? state.cableTypes[0].id;
+ const typeOptions = state.cableTypes
+ .map((t) => `${escapeHtml(t.name)} `)
+ .join("");
+
+ body.innerHTML = `
+ Add port
+
+ ← ${escapeHtml(dev.name)}
+
+
+ Type
+ ${typeOptions}
+
+
+ Edge
+
+ Top
+ Right
+ Bottom
+ Left
+
+
+
+ Label
+
+
+
+ Create
+ Cancel
+
+ `;
+ const typeSel = /** @type {HTMLSelectElement} */ (body.querySelector("#port-new-type"));
+ const edgeSel = /** @type {HTMLSelectElement} */ (body.querySelector("#port-new-edge"));
+ const labelInp = /** @type {HTMLInputElement} */ (body.querySelector("#port-new-label"));
+ typeSel.value = String(defaultTypeID);
+ labelInp.value = nextDefaultPortLabel(dev.id, defaultTypeID);
+ labelInp.placeholder = labelInp.value;
+
+ // Recompute default label whenever the type changes (only if m hasn't
+ // edited the field).
+ let labelUserEdited = false;
+ labelInp.addEventListener("input", () => { labelUserEdited = true; });
+ typeSel.addEventListener("change", () => {
+ if (labelUserEdited) return;
+ const tid = Number(typeSel.value);
+ const next = nextDefaultPortLabel(dev.id, tid);
+ labelInp.value = next;
+ labelInp.placeholder = next;
+ });
+
+ body.querySelector("#port-new-back").addEventListener("click", (e) => {
+ e.preventDefault();
+ state.selection = { kind: "device", id: dev.id };
+ render();
+ });
+ body.querySelector("#port-new-cancel").addEventListener("click", () => {
+ state.selection = { kind: "device", id: dev.id };
+ render();
+ });
+ body.querySelector("#port-new-create").addEventListener("click", async () => {
+ const tid = Number(typeSel.value);
+ const edge = edgeSel.value;
+ const label = labelInp.value.trim();
+ await createPortFromForm(dev.id, tid, edge, label);
+ });
+}
+
function renderInspectorCableType(body, id) {
const t = state.cableTypes.find((x) => x.id === id);
if (!t) { body.innerHTML = ""; return; }
@@ -1313,27 +1453,15 @@ function armTool(tool) {
const wrap = $(".canvas-wrap");
wrap.classList.toggle("tool-frame", tool === "frame");
wrap.classList.toggle("tool-device", tool === "device");
- wrap.classList.toggle("tool-port", tool === "port");
wrap.classList.toggle("tool-cable", tool === "cable");
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
}
- if (tool !== "port") {
- state.portToolDevice = null;
- state.portToolTypeID = null;
- }
if (tool !== "cable") {
state.cableDrawFromPortID = null;
}
}
-/** Slice 7: device inspector arms +Port for a specific device + type. */
-function armPortTool(deviceID, typeID) {
- state.portToolDevice = deviceID;
- state.portToolTypeID = typeID;
- armTool("port");
-}
-
function bindTools() {
for (const btn of document.querySelectorAll("[data-tool]")) {
btn.addEventListener("click", () => armTool(btn.getAttribute("data-tool")));
@@ -1385,11 +1513,6 @@ function onCanvasPointerDown(e) {
placeDeviceAt(p);
return;
}
- if (state.tool === "port") {
- e.preventDefault();
- placePortAt(p);
- return;
- }
if (state.tool === "io") {
e.preventDefault();
placeIOMarkerAt(p);
@@ -1568,27 +1691,8 @@ 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) {
- // 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);
- const dyTop = Math.abs(y - device.y);
- const dyBottom = Math.abs((device.y + device.height) - y);
- const min = Math.min(dxLeft, dxRight, dyTop, dyBottom);
- // 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" };
-}
-
// 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.
+// existing (x_offset, y_offset) to the nearest of the four edges.
function portEdge(port, device) {
const dL = port.x_offset;
const dR = device.width - port.x_offset;
@@ -1762,33 +1866,28 @@ async function finishCableDrawAtIO(ioMarker) {
}
}
-async function placePortAt(p) {
+// Create a port from the sidebar "Add port" form and switch the
+// inspector to its editor. Used by renderInspectorPortNew on submit.
+async function createPortFromForm(deviceID, typeID, edge, label) {
if (!state.active) return;
- const did = state.portToolDevice;
- const tid = state.portToolTypeID;
- 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 dev = state.devices.find((d) => d.id === deviceID);
+ if (!dev) return;
+ const tmp = edgeCentre(dev, edge);
try {
- const port = await createPort(state.active.id, did, {
- type_id: tid,
- x_offset: snap.xOff,
- y_offset: snap.yOff,
+ const port = await createPort(state.active.id, deviceID, {
+ type_id: typeID,
+ label: label || undefined,
+ x_offset: tmp.xOff,
+ y_offset: tmp.yOff,
});
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.
+ // Re-space every port on this edge so the new one slots into the
+ // even-spacing grid.
+ await relayoutEdge(deviceID, edge);
state.selection = { kind: "port", id: port.id };
- armTool(null);
render();
} catch (e) {
alert(`Add port failed: ${e.message}`);
- armTool(null);
}
}
diff --git a/web/static/style.css b/web/static/style.css
index 84e041c..d093a3f 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -215,8 +215,6 @@ body {
.canvas-wrap.tool-device #canvas *,
.canvas-wrap.tool-io #canvas,
.canvas-wrap.tool-io #canvas *,
-.canvas-wrap.tool-port #canvas,
-.canvas-wrap.tool-port #canvas *,
.canvas-wrap.tool-cable #canvas,
.canvas-wrap.tool-cable #canvas * { cursor: crosshair !important; }
@@ -296,8 +294,11 @@ body {
align-items: center;
gap: 6px;
font-size: 12px;
- padding: 2px 0;
+ padding: 2px 4px;
+ border-radius: 4px;
+ cursor: pointer;
}
+.port-row:hover { background: var(--surface-2); }
.port-row .swatch,
.swatch {
display: inline-block;