feat(ui): +Port tool + manual cable draw + driving-req link
+Port (device inspector): - New button on the device inspector arms a port-placement tool with the device + currently-active cable type pre-selected. - Click anywhere on the canvas: snapToDeviceEdge() finds the closest edge of the selected device, clamps the perpendicular coord, POSTs a new port. The new port renders immediately (state.ports.push + render()). - Per-port × delete button in the inspector ports grid. Manual cable draw: - Port circles are now clickable (slice 4 had pointer-events:none). - Click a port → starts a cable draw with that port as the source (state.cableDrawFromPortID, port highlighted via .cable-from class). - Click another port → POSTs a cable with from_port_id + to_port_id, type derived from source port, auto=false. If the target port's type differs, confirm-prompt warns m before committing. - Shift+click target port → binds to the target's parent device (to_device_id) instead of the port. - Click an IO marker mid-draw → terminates the cable with to_io_id. - Esc cancels the draw + clears state.cableDrawFromPortID. - "Draw cable" toolbar button is now enabled (data-tool=cable, keyboard is implicit via port-click). armTool() teardown clears the source-port state. Cable inspector tweak (slice 6 callback): - "driver" row now renders as a clickable button showing the requirement's "FromName ↔ ToName" instead of the raw id; click jumps the inspector to that requirement. CSS: - tool-port + tool-cable add the same crosshair cursor as the other tools (descendant-targeted with !important to beat svg-draggable's grab cursor — same fix-pattern as slice 3's cursor-cache pass). - .port-circle.cable-from gives the source port a glow. - .btn-link styles for inspector inline buttons.
This commit is contained in:
@@ -46,7 +46,7 @@
|
||||
<li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li>
|
||||
<li><button type="button" id="tool-io" class="btn btn-tiny" data-tool="io">+ IO</button></li>
|
||||
<li><button type="button" id="tool-req" class="btn btn-tiny" data-tool="req">Drag req A→B</button></li>
|
||||
<li><button type="button" class="btn btn-tiny" disabled title="Slice 7">Draw cable</button></li>
|
||||
<li><button type="button" id="tool-cable" class="btn btn-tiny" data-tool="cable" title="Click a port to start, then click another port / device / IO marker">Draw cable</button></li>
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
@@ -56,8 +56,13 @@ const state = {
|
||||
/** @type {Bundle[]} */ bundles: [],
|
||||
/** @type {SetupTemplate[]} */ setupTemplates: [],
|
||||
activeTypeId: /** @type {number|null} */ (null),
|
||||
/** "frame" | "device" | "io" | "req" | null */
|
||||
/** "frame" | "device" | "io" | "req" | "port" | "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", id: number} | null} */ selection: null,
|
||||
};
|
||||
|
||||
@@ -104,6 +109,12 @@ const createIOMarker = (pid, body) => api("POST", `/projects/${pid}/io-markers
|
||||
const patchIOMarker = (pid, id, body) => api("PATCH", `/projects/${pid}/io-markers/${id}`, body);
|
||||
const deleteIOMarker = (pid, id) => api("DELETE", `/projects/${pid}/io-markers/${id}`);
|
||||
|
||||
const createPort = (pid, devId, body) => api("POST", `/projects/${pid}/devices/${devId}/ports`, body);
|
||||
const patchPort = (pid, id, body) => api("PATCH", `/projects/${pid}/ports/${id}`, body);
|
||||
const deletePort = (pid, id) => api("DELETE", `/projects/${pid}/ports/${id}`);
|
||||
|
||||
const createCableAPI = (pid, body) => api("POST", `/projects/${pid}/cables`, body);
|
||||
|
||||
const listDeviceTypesForProject = (pid) => api("GET", `/projects/${pid}/device-types`);
|
||||
|
||||
const createRequirement = (pid, body) => api("POST", `/projects/${pid}/connection-requirements`, body);
|
||||
@@ -305,11 +316,15 @@ function renderCanvas() {
|
||||
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 c = svgEl("circle", {
|
||||
cx, cy, r: 5,
|
||||
class: "port-circle",
|
||||
class: cls,
|
||||
stroke: color,
|
||||
"data-port-id": prt.id,
|
||||
});
|
||||
// Slice 7: port-click drives the manual cable-draw flow.
|
||||
c.addEventListener("pointerdown", (e) => onPortPointerDown(e, prt));
|
||||
g.append(c);
|
||||
}
|
||||
|
||||
@@ -339,7 +354,17 @@ function renderCanvas() {
|
||||
label.textContent = m.label;
|
||||
g.append(rect, label);
|
||||
gIO.append(g);
|
||||
rect.addEventListener("pointerdown", (e) => startDrag(e, "io", m.id));
|
||||
rect.addEventListener("pointerdown", (e) => {
|
||||
// Slice 7: if a cable draw is in progress, terminate the cable on
|
||||
// this IO marker instead of starting a drag.
|
||||
if (state.cableDrawFromPortID != null) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
finishCableDrawAtIO(m);
|
||||
return;
|
||||
}
|
||||
startDrag(e, "io", m.id);
|
||||
});
|
||||
}
|
||||
|
||||
// Cables — straight lines between resolved endpoint anchors.
|
||||
@@ -469,9 +494,25 @@ function renderInspectorCable(body, id) {
|
||||
body.querySelector("#cab-type").textContent = ct ? `${ct.name}` : `type #${c.type_id}`;
|
||||
body.querySelector("#cab-from").textContent = fromLabel;
|
||||
body.querySelector("#cab-to").textContent = toLabel;
|
||||
body.querySelector("#cab-driver").textContent = drivingReq
|
||||
? `requirement #${drivingReq.id}`
|
||||
: (c.auto ? "(no matching requirement)" : "—");
|
||||
const driverCell = body.querySelector("#cab-driver");
|
||||
if (drivingReq) {
|
||||
const deviceByID2 = new Map(state.devices.map((d) => [d.id, d]));
|
||||
const an = deviceByID2.get(drivingReq.from_device_id)?.name ?? "?";
|
||||
const bn = deviceByID2.get(drivingReq.to_device_id)?.name ?? "?";
|
||||
const link = document.createElement("button");
|
||||
link.type = "button";
|
||||
link.className = "btn-link";
|
||||
link.style.padding = "0";
|
||||
link.textContent = `${an} ↔ ${bn}`;
|
||||
link.title = "Jump to this requirement";
|
||||
link.addEventListener("click", () => {
|
||||
state.selection = { kind: "requirement", id: drivingReq.id };
|
||||
render();
|
||||
});
|
||||
driverCell.append(link);
|
||||
} else {
|
||||
driverCell.textContent = c.auto ? "(no matching requirement)" : "—";
|
||||
}
|
||||
|
||||
if (c.auto) {
|
||||
body.querySelector("#cab-promote").addEventListener("click", async () => {
|
||||
@@ -557,10 +598,12 @@ function renderInspectorDevice(body, id) {
|
||||
|
||||
const portsHtml = ports.length
|
||||
? ports.map((p) => `
|
||||
<div class="port-row">
|
||||
<div class="port-row" data-port-id="${p.id}">
|
||||
<span class="swatch" style="background:${cableTypeColor.get(p.type_id) || "#888"}"></span>
|
||||
<span class="label">${escapeHtml(p.label ?? cableTypeName.get(p.type_id) ?? "Port")}</span>
|
||||
<span class="conn">unconnected</span>
|
||||
<span class="conn">
|
||||
<button type="button" class="btn-link port-del" data-port-id="${p.id}" title="Delete port">×</button>
|
||||
</span>
|
||||
</div>`).join("")
|
||||
: `<p class="muted" style="font-size:12px">No ports yet.</p>`;
|
||||
|
||||
@@ -607,6 +650,9 @@ function renderInspectorDevice(body, id) {
|
||||
</dl>
|
||||
<p class="section-title">Ports</p>
|
||||
<div id="dev-ports">${portsHtml}</div>
|
||||
<div class="inspector-actions" style="margin-top: 4px;">
|
||||
<button type="button" class="btn btn-tiny" id="dev-add-port">+ Port</button>
|
||||
</div>
|
||||
<p class="section-title">Requirements</p>
|
||||
<div id="dev-reqs">${reqsHtml}</div>
|
||||
<div class="inspector-actions">
|
||||
@@ -669,6 +715,35 @@ function renderInspectorDevice(body, id) {
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
// +Port — arms the port-placement gesture. Active cable type comes
|
||||
// from the legend selection; if none, defaults to the first cable_type.
|
||||
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);
|
||||
});
|
||||
|
||||
// Per-port delete.
|
||||
body.querySelectorAll(".port-del").forEach((btn) => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!state.active) return;
|
||||
const pid = Number(btn.getAttribute("data-port-id"));
|
||||
if (!pid) return;
|
||||
if (!confirm("Delete this port?")) return;
|
||||
try {
|
||||
await deletePort(state.active.id, pid);
|
||||
state.ports = state.ports.filter((p) => p.id !== pid);
|
||||
// Cables that referenced the port get from_port_id/to_port_id
|
||||
// set to NULL by the schema — refresh from snapshot.
|
||||
const snap = await getSnapshot(state.active.id);
|
||||
state.cables = snap.cables || [];
|
||||
render();
|
||||
} catch (ex) { alert(`Delete failed: ${ex.message}`); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderInspectorRequirement(body, id) {
|
||||
@@ -1120,9 +1195,25 @@ 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() {
|
||||
@@ -1134,7 +1225,7 @@ function bindTools() {
|
||||
// Avoid stealing keys while user is typing into an input.
|
||||
const tag = (e.target instanceof HTMLElement) ? e.target.tagName : "";
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||
if (e.key === "Escape") { armTool(null); cancelInlineNamer(); state.selection = null; render(); }
|
||||
if (e.key === "Escape") { armTool(null); cancelInlineNamer(); state.cableDrawFromPortID = null; state.selection = null; render(); }
|
||||
else if (e.key === "f" || e.key === "F") armTool("frame");
|
||||
else if (e.key === "d" || e.key === "D") armTool("device");
|
||||
else if (e.key === "i" || e.key === "I") armTool("io");
|
||||
@@ -1176,6 +1267,11 @@ function onCanvasPointerDown(e) {
|
||||
placeDeviceAt(p);
|
||||
return;
|
||||
}
|
||||
if (state.tool === "port") {
|
||||
e.preventDefault();
|
||||
placePortAt(p);
|
||||
return;
|
||||
}
|
||||
if (state.tool === "io") {
|
||||
e.preventDefault();
|
||||
placeIOMarkerAt(p);
|
||||
@@ -1354,6 +1450,136 @@ 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" };
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
async function finishCableDrawAt(targetPort, shiftKey) {
|
||||
if (!state.active) return;
|
||||
const fromPortID = state.cableDrawFromPortID;
|
||||
state.cableDrawFromPortID = null;
|
||||
armTool(null);
|
||||
if (fromPortID == null) return;
|
||||
const sourcePort = state.ports.find((p) => p.id === fromPortID);
|
||||
if (!sourcePort) { render(); return; }
|
||||
|
||||
// Body: shift-click on a port = bind to that port's parent device
|
||||
// (whole-device cable) instead of the port. Plain click = port-to-port.
|
||||
const body = {
|
||||
type_id: sourcePort.type_id,
|
||||
auto: false,
|
||||
from: { port_id: fromPortID },
|
||||
to: shiftKey ? { device_id: targetPort.device_id } : { port_id: targetPort.id },
|
||||
};
|
||||
if (!shiftKey && targetPort.type_id !== sourcePort.type_id) {
|
||||
if (!confirm(`Target port is a different cable type. Connect anyway?`)) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const c = await createCableAPI(state.active.id, body);
|
||||
state.cables.push(c);
|
||||
state.selection = { kind: "cable", id: c.id };
|
||||
render();
|
||||
} catch (e) {
|
||||
alert(`Create cable failed: ${e.message}`);
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
/** Click on an IO marker while a cable draw is in progress → terminate
|
||||
* the cable on that IO. Plugged into the IO marker's pointerdown
|
||||
* handler in renderCanvas. */
|
||||
async function finishCableDrawAtIO(ioMarker) {
|
||||
if (!state.active) return;
|
||||
const fromPortID = state.cableDrawFromPortID;
|
||||
state.cableDrawFromPortID = null;
|
||||
armTool(null);
|
||||
if (fromPortID == null) return;
|
||||
const sourcePort = state.ports.find((p) => p.id === fromPortID);
|
||||
if (!sourcePort) { render(); return; }
|
||||
const body = {
|
||||
type_id: sourcePort.type_id,
|
||||
auto: false,
|
||||
from: { port_id: fromPortID },
|
||||
to: { io_id: ioMarker.id },
|
||||
};
|
||||
try {
|
||||
const c = await createCableAPI(state.active.id, body);
|
||||
state.cables.push(c);
|
||||
state.selection = { kind: "cable", id: c.id };
|
||||
render();
|
||||
} catch (e) {
|
||||
alert(`Create cable failed: ${e.message}`);
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function placePortAt(p) {
|
||||
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);
|
||||
try {
|
||||
const port = await createPort(state.active.id, did, {
|
||||
type_id: tid,
|
||||
x_offset: snap.xOff,
|
||||
y_offset: snap.yOff,
|
||||
});
|
||||
state.ports.push(port);
|
||||
armTool(null);
|
||||
render();
|
||||
} catch (e) {
|
||||
alert(`Add port failed: ${e.message}`);
|
||||
armTool(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function placeIOMarkerAt(p) {
|
||||
if (!state.active) return;
|
||||
armTool(null);
|
||||
|
||||
@@ -213,7 +213,28 @@ body {
|
||||
.canvas-wrap.tool-device #canvas,
|
||||
.canvas-wrap.tool-device #canvas *,
|
||||
.canvas-wrap.tool-io #canvas,
|
||||
.canvas-wrap.tool-io #canvas * { cursor: crosshair !important; }
|
||||
.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; }
|
||||
|
||||
.btn-link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-link:hover { color: var(--danger); }
|
||||
|
||||
/* Highlight a port that's been picked as the cable-draw source. */
|
||||
.port-circle.cable-from {
|
||||
stroke-width: 3;
|
||||
filter: drop-shadow(0 0 4px var(--accent));
|
||||
}
|
||||
|
||||
/* IO markers — diamonds. Power-by-convention, so the default fill is
|
||||
the Power cable_type colour (#e03131). Rotated 45° rect is the
|
||||
@@ -246,7 +267,7 @@ body {
|
||||
fill: #fff;
|
||||
stroke: var(--text);
|
||||
stroke-width: 2;
|
||||
pointer-events: none; /* slice 4 — selection happens at device-level */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.port-row {
|
||||
|
||||
Reference in New Issue
Block a user