feat(ui): bottom-right resize handle on devices

m: 'I want the size of devices to be customizable. A resize function at
the bottom right corner would be good.'

- 10×10 SVG handle drawn at each device's bottom-right corner with class
  .device-resize-handle + cursor: nwse-resize. Subtle grey by default,
  darker on hover so m can find it without it dominating the rect.
- startResize captures the pointer, stops propagation so the rect's
  pointerdown (= startDrag) doesn't also fire, and updates the local
  device.width / .height on every pointermove using svgPoint deltas —
  works at any zoom level via the same world-coord conversion the rest
  of the canvas uses.
- Clamps to 60×30 minimum during the drag so the rect can't collapse.
- On pointerup: PATCH /devices/:id with the new width + height, then
  relayoutAllEdges(deviceID) so ports on every edge redistribute to
  their i/(N+1) positions against the new dimensions. Right- and
  bottom-edge ports get the visible adjustment; top/left re-space too
  but their absolute positions don't change.
This commit is contained in:
mAi
2026-05-16 12:59:51 +02:00
parent 57a9154f18
commit 89686d0c1f
2 changed files with 84 additions and 0 deletions

View File

@@ -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.

View File

@@ -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;