merge: device resize handle (bottom-right corner)

10x10 handle on every device, drag to resize. Min 60x30. On pointerup,
PATCH width/height + relayoutAllEdges so ports re-distribute. stopPropagation
keeps the body drag separate from the handle drag. Works at any zoom.
This commit is contained in:
mAi
2026-05-16 13:07:31 +02:00
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;