From 17e6b5e91ce6e7680c83cacb9c75c737cbbdfffe Mon Sep 17 00:00:00 2001 From: mAi Date: Sat, 16 May 2026 13:11:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20cable=20endpoint=20replug=20?= =?UTF-8?q?=E2=80=94=20drag=20handles=20to=20a=20new=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m can grab either end of a selected cable and drop it on a different port / device / IO marker. Mechanics: - Selected cable renders two .cable-handle circles at its endpoints (handle radius 7, filled in the cable's colour with a white halo + drop-shadow). Hidden unless the cable is selected so unrelated cables don't litter the canvas with grab points. - pointerdown on a handle calls startCableReplug; the module-level cableReplug = {cableID, end, x, y} drives renderCanvas to anchor the affected endpoint at the cursor in world coords. Pointermove keeps the line tracking; pointerup hit-tests the cursor via elementsFromPoint (skipping the cable-handle itself). - Drop target: port → PATCH {from|to: {port_id}} device → PATCH {from|to: {device_id}} IO → PATCH {from|to: {io_id}} empty / same endpoint → cancel (no PATCH) - When the cable was auto=1 and the drop commits, the PATCH also sends promote=true so the server flips it to manual — m took control. - preventDefault + stopPropagation on the handle pointerdown so canvas panning / cable-line clicks don't interfere. Pointer capture survives the drag leaving the SVG bounds. CSS: .cable-handle gets grab cursor + drop-shadow; .replugging on the canvas-wrap promotes to grabbing during the gesture. --- web/static/main.js | 124 +++++++++++++++++++++++++++++++++++++++++-- web/static/style.css | 11 ++++ 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/web/static/main.js b/web/static/main.js index e09cb94..1116ce7 100644 --- a/web/static/main.js +++ b/web/static/main.js @@ -534,14 +534,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 +559,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 +1663,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 +2004,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. diff --git a/web/static/style.css b/web/static/style.css index 27b7584..a54bbad 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -407,6 +407,17 @@ body { .cable-line:hover { stroke-width: 4; } .cable-line.selected { stroke-width: 4; } +/* Endpoint handles — only rendered for the currently-selected cable. + Grab cursor on idle, grabbing while dragging (.replugging on root). */ +.cable-handle { + cursor: grab; + stroke-width: 2; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.35)); +} +.cable-handle:hover { stroke-width: 3; } +.canvas-wrap.replugging .cable-handle, +.canvas-wrap.replugging #canvas * { cursor: grabbing !important; } + /* Solve preview-diff modal */ .modal-wide { width: 560px; }