|
|
|
|
@@ -471,6 +471,18 @@ function renderCanvas() {
|
|
|
|
|
});
|
|
|
|
|
// Port-click drives both cable-draw (slice 7) and port-select (this fix).
|
|
|
|
|
c.addEventListener("pointerdown", (e) => onPortPointerDown(e, prt));
|
|
|
|
|
// Double-click activates cable-draw mode from this port without arming
|
|
|
|
|
// the cable tool first. armTool("cable") gives the crosshair cursor;
|
|
|
|
|
// the next port-click is then caught by onPortPointerDown's
|
|
|
|
|
// cable-draw-in-progress branch and commits the cable.
|
|
|
|
|
c.addEventListener("dblclick", (e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (state.tool !== "cable") armTool("cable");
|
|
|
|
|
state.cableDrawFromPortID = prt.id;
|
|
|
|
|
state.selection = null;
|
|
|
|
|
render();
|
|
|
|
|
});
|
|
|
|
|
g.append(c);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -534,14 +546,22 @@ function renderCanvas() {
|
|
|
|
|
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
|
|
|
|
|
const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m]));
|
|
|
|
|
for (const c of state.cables) {
|
|
|
|
|
const fromAnchor = anchorForEndpoint(c.from_port_id, c.from_device_id, c.from_io_id, portByID, deviceByID, ioByID);
|
|
|
|
|
const toAnchor = anchorForEndpoint(c.to_port_id, c.to_device_id, c.to_io_id, portByID, deviceByID, ioByID);
|
|
|
|
|
let fromAnchor = anchorForEndpoint(c.from_port_id, c.from_device_id, c.from_io_id, portByID, deviceByID, ioByID);
|
|
|
|
|
let toAnchor = anchorForEndpoint(c.to_port_id, c.to_device_id, c.to_io_id, portByID, deviceByID, ioByID);
|
|
|
|
|
if (!fromAnchor || !toAnchor) continue;
|
|
|
|
|
// Replug preview: while m drags an endpoint handle, override the
|
|
|
|
|
// affected end with the live cursor world position so the line
|
|
|
|
|
// tracks the pointer.
|
|
|
|
|
if (cableReplug && cableReplug.cableID === c.id) {
|
|
|
|
|
if (cableReplug.end === "from") fromAnchor = { x: cableReplug.x, y: cableReplug.y };
|
|
|
|
|
else toAnchor = { x: cableReplug.x, y: cableReplug.y };
|
|
|
|
|
}
|
|
|
|
|
const isSelected = state.selection?.kind === "cable" && state.selection.id === c.id;
|
|
|
|
|
const color = cableTypeColor.get(c.type_id) || "#888";
|
|
|
|
|
const line = svgEl("line", {
|
|
|
|
|
x1: fromAnchor.x, y1: fromAnchor.y,
|
|
|
|
|
x2: toAnchor.x, y2: toAnchor.y,
|
|
|
|
|
class: "cable-line" + (c.auto ? " auto" : "") + (state.selection?.kind === "cable" && state.selection.id === c.id ? " selected" : ""),
|
|
|
|
|
class: "cable-line" + (c.auto ? " auto" : "") + (isSelected ? " selected" : ""),
|
|
|
|
|
stroke: color,
|
|
|
|
|
"data-cable-id": c.id,
|
|
|
|
|
});
|
|
|
|
|
@@ -551,6 +571,23 @@ function renderCanvas() {
|
|
|
|
|
render();
|
|
|
|
|
});
|
|
|
|
|
gCables.append(line);
|
|
|
|
|
// Endpoint handles — only on the currently-selected cable. Two small
|
|
|
|
|
// filled circles m can grab to drag the endpoint onto a new target.
|
|
|
|
|
if (isSelected) {
|
|
|
|
|
for (const end of ["from", "to"]) {
|
|
|
|
|
const a = end === "from" ? fromAnchor : toAnchor;
|
|
|
|
|
const h = svgEl("circle", {
|
|
|
|
|
cx: a.x, cy: a.y, r: 7,
|
|
|
|
|
class: "cable-handle",
|
|
|
|
|
fill: color,
|
|
|
|
|
stroke: "#fff",
|
|
|
|
|
"data-cable-id": c.id,
|
|
|
|
|
"data-end": end,
|
|
|
|
|
});
|
|
|
|
|
h.addEventListener("pointerdown", (e) => startCableReplug(e, c.id, end));
|
|
|
|
|
gCables.append(h);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -1638,6 +1675,10 @@ function bindTools() {
|
|
|
|
|
|
|
|
|
|
let rubberBand = /** @type {SVGRectElement|null} */ (null);
|
|
|
|
|
let rubberStart = /** @type {{x:number,y:number}|null} */ (null);
|
|
|
|
|
// Live state for a cable-endpoint replug drag. Captured at pointerdown
|
|
|
|
|
// on a .cable-handle, used by renderCanvas to anchor the dragged end
|
|
|
|
|
// at the cursor; cleared on pointerup (commit or cancel).
|
|
|
|
|
let cableReplug = /** @type {{cableID: number, end: "from"|"to", x: number, y: number}|null} */ (null);
|
|
|
|
|
|
|
|
|
|
function onCanvasPointerDown(e) {
|
|
|
|
|
// Pan gestures win over every tool. Middle-click and Space+drag both
|
|
|
|
|
@@ -1975,6 +2016,95 @@ function startResize(e, deviceID) {
|
|
|
|
|
svg.addEventListener("pointercancel", onUp);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find the topmost canvas element under (clientX, clientY) that maps to
|
|
|
|
|
// a cable endpoint target. Returns { kind, id } for port / device / IO,
|
|
|
|
|
// or null when m dropped on empty canvas.
|
|
|
|
|
function hitTestEndpointTarget(clientX, clientY) {
|
|
|
|
|
// elementsFromPoint walks the z-order so we can skip the dragged
|
|
|
|
|
// cable handle itself (it sits at the top while pointer-captured).
|
|
|
|
|
const els = document.elementsFromPoint(clientX, clientY);
|
|
|
|
|
for (const el of els) {
|
|
|
|
|
if (!(el instanceof Element)) continue;
|
|
|
|
|
if (el.classList?.contains("cable-handle")) continue; // skip self
|
|
|
|
|
const portID = el.getAttribute && el.getAttribute("data-port-id");
|
|
|
|
|
if (portID) return { kind: "port", id: Number(portID) };
|
|
|
|
|
const devEl = el.closest && el.closest("[data-device-id]");
|
|
|
|
|
if (devEl) return { kind: "device", id: Number(devEl.getAttribute("data-device-id")) };
|
|
|
|
|
const ioEl = el.closest && el.closest("[data-io-id]");
|
|
|
|
|
if (ioEl) return { kind: "io", id: Number(ioEl.getAttribute("data-io-id")) };
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Endpoint-drag gesture: pointerdown on a .cable-handle starts a replug.
|
|
|
|
|
// While held, renderCanvas anchors the affected end at the cursor.
|
|
|
|
|
// On pointerup, hit-test the cursor to find the drop target:
|
|
|
|
|
// - port → PATCH {from|to: {port_id}}
|
|
|
|
|
// - device → PATCH {from|to: {device_id}}
|
|
|
|
|
// - IO → PATCH {from|to: {io_id}}
|
|
|
|
|
// - empty → cancel (revert)
|
|
|
|
|
// When the cable was auto, a successful drop also sends promote=true so
|
|
|
|
|
// the server flips it to manual (m took control). Cancel leaves auto alone.
|
|
|
|
|
function startCableReplug(e, cableID, end) {
|
|
|
|
|
if (!state.active) return;
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const c = state.cables.find((x) => x.id === cableID);
|
|
|
|
|
if (!c) return;
|
|
|
|
|
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
|
|
|
|
|
try { svg.setPointerCapture(e.pointerId); } catch {}
|
|
|
|
|
$(".canvas-wrap").classList.add("replugging");
|
|
|
|
|
const startWorld = svgPoint(e);
|
|
|
|
|
cableReplug = { cableID, end, x: startWorld.x, y: startWorld.y };
|
|
|
|
|
renderCanvas();
|
|
|
|
|
|
|
|
|
|
const onMove = (ev) => {
|
|
|
|
|
const p = svgPoint(ev);
|
|
|
|
|
cableReplug = { cableID, end, x: p.x, y: p.y };
|
|
|
|
|
renderCanvas();
|
|
|
|
|
};
|
|
|
|
|
const onUp = async (ev) => {
|
|
|
|
|
svg.removeEventListener("pointermove", onMove);
|
|
|
|
|
svg.removeEventListener("pointerup", onUp);
|
|
|
|
|
svg.removeEventListener("pointercancel", onUp);
|
|
|
|
|
try { svg.releasePointerCapture(ev.pointerId); } catch {}
|
|
|
|
|
$(".canvas-wrap").classList.remove("replugging");
|
|
|
|
|
const drop = hitTestEndpointTarget(ev.clientX, ev.clientY);
|
|
|
|
|
// Clear the preview first so renderCanvas falls back to resolved anchors.
|
|
|
|
|
cableReplug = null;
|
|
|
|
|
if (!drop) {
|
|
|
|
|
renderCanvas();
|
|
|
|
|
return; // cancel
|
|
|
|
|
}
|
|
|
|
|
// Build the patch for the affected endpoint.
|
|
|
|
|
const ep =
|
|
|
|
|
drop.kind === "port" ? { port_id: drop.id } :
|
|
|
|
|
drop.kind === "device" ? { device_id: drop.id } :
|
|
|
|
|
drop.kind === "io" ? { io_id: drop.id } : null;
|
|
|
|
|
if (!ep) { renderCanvas(); return; }
|
|
|
|
|
const body = {};
|
|
|
|
|
if (end === "from") body.from = ep; else body.to = ep;
|
|
|
|
|
if (c.auto) body.promote = true;
|
|
|
|
|
// If m dropped on the same endpoint we already had, treat as cancel.
|
|
|
|
|
const sameAsBefore =
|
|
|
|
|
(drop.kind === "port" && ((end === "from" ? c.from_port_id : c.to_port_id) === drop.id)) ||
|
|
|
|
|
(drop.kind === "device" && ((end === "from" ? c.from_device_id : c.to_device_id) === drop.id)) ||
|
|
|
|
|
(drop.kind === "io" && ((end === "from" ? c.from_io_id : c.to_io_id) === drop.id));
|
|
|
|
|
if (sameAsBefore) { renderCanvas(); return; }
|
|
|
|
|
try {
|
|
|
|
|
const updated = await patchCable(state.active.id, c.id, body);
|
|
|
|
|
Object.assign(c, updated);
|
|
|
|
|
render();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
alert(`Replug failed: ${err.message}`);
|
|
|
|
|
renderCanvas();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
svg.addEventListener("pointermove", onMove);
|
|
|
|
|
svg.addEventListener("pointerup", onUp);
|
|
|
|
|
svg.addEventListener("pointercancel", onUp);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Port-click flow:
|
|
|
|
|
* - A cable draw is in progress (cableDrawFromPortID set):
|
|
|
|
|
* same port → cancel; another port → finish the cable.
|
|
|
|
|
|