diff --git a/web/static/main.js b/web/static/main.js index 4e118c3..e09cb94 100644 --- a/web/static/main.js +++ b/web/static/main.js @@ -474,6 +474,20 @@ function renderCanvas() { g.append(c); } + // Bottom-right resize handle. Drawn last so it sits on top of the rect + // and any port circles that might overlap the corner. Visible always + // but subtle; cursor signals resize affordance. + const HSZ = 10; + const handle = svgEl("rect", { + x: d.x + d.width - HSZ, + y: d.y + d.height - HSZ, + width: HSZ, height: HSZ, + class: "device-resize-handle", + "data-device-id": d.id, + }); + handle.addEventListener("pointerdown", (e) => startResize(e, d.id)); + g.append(handle); + gDevices.append(g); rect.addEventListener("pointerdown", (e) => startDrag(e, "device", d.id)); } @@ -1903,6 +1917,64 @@ async function relayoutEdge(deviceID, edge) { } } +// Re-space ports on every edge of `deviceID`. Used after the device's +// width / height change so all four edges recompute the i/(N+1) +// positions against the new dimensions. +async function relayoutAllEdges(deviceID) { + await Promise.all([ + relayoutEdge(deviceID, "top"), + relayoutEdge(deviceID, "right"), + relayoutEdge(deviceID, "bottom"), + relayoutEdge(deviceID, "left"), + ]); +} + +// Bottom-right resize handle gesture. Updates width / height in local +// state on each move (renderCanvas redraws the rect + ports), clamps to +// a minimum so the device can't collapse, then PATCHes the new size on +// pointerup and re-spaces every edge's ports. +function startResize(e, deviceID) { + if (!state.active) return; + // Hard-stop so the rect's pointerdown doesn't also fire startDrag. + e.stopPropagation(); + e.preventDefault(); + const d = state.devices.find((x) => x.id === deviceID); + if (!d) return; + const startWidth = d.width, startHeight = d.height; + const startWorld = svgPoint(e); + const svg = /** @type {SVGSVGElement} */ ($("#canvas")); + try { svg.setPointerCapture(e.pointerId); } catch {} + const MIN_W = 60, MIN_H = 30; + const onMove = (ev) => { + const p = svgPoint(ev); + d.width = Math.max(MIN_W, startWidth + (p.x - startWorld.x)); + d.height = Math.max(MIN_H, startHeight + (p.y - startWorld.y)); + renderCanvas(); + }; + const onUp = async (ev) => { + svg.removeEventListener("pointermove", onMove); + svg.removeEventListener("pointerup", onUp); + svg.removeEventListener("pointercancel", onUp); + try { svg.releasePointerCapture(ev.pointerId); } catch {} + if (d.width === startWidth && d.height === startHeight) return; + try { + const updated = await patchDevice(state.active.id, d.id, { + width: d.width, height: d.height, + }); + Object.assign(d, updated); + // Ports may have been on an edge that just moved (right or bottom) + // — re-distribute everything to the new dims. + await relayoutAllEdges(d.id); + renderCanvas(); + } catch (err) { + alert(`Resize failed: ${err.message}`); + } + }; + 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 6c0c547..27b7584 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -192,6 +192,18 @@ body { .device-rect.selected { stroke-width: 3; } .device-rect:hover { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15)); } +/* Bottom-right resize affordance per device. Subtle grey by default, + stronger on hover so m can find it without it dominating the rect. */ +.device-resize-handle { + fill: rgba(120, 120, 120, 0.35); + stroke: rgba(60, 60, 60, 0.45); + stroke-width: 1; + cursor: nwse-resize; +} +.device-resize-handle:hover { + fill: rgba(60, 60, 60, 0.65); +} + .device-label { fill: var(--text); font-size: 12px;