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) => ``) + .join(""); body.innerHTML = `

Port

-
-
device
${dev.name}
-
type
-
${ctName}
-
+

+ ← ${escapeHtml(dev.name)} +

+
`; - 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.

+
+ +
`; + 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) => ``) + .join(""); + + body.innerHTML = ` +

Add port

+

+ ← ${escapeHtml(dev.name)} +

+ + + +
+ + +
+ `; + 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;