merge: frame resize handle (bottom-right corner)

Mirrors picasso's device-resize pattern. 10x10 handle at frame's
bottom-right; pointerdown→drag→PATCH width+height on release. Min
200x150. Doesn't interfere with frame-body drag or label-as-grip
selection.
This commit is contained in:
mAi
2026-05-17 17:21:52 +02:00
2 changed files with 73 additions and 0 deletions

View File

@@ -480,6 +480,21 @@ function renderCanvas() {
}); });
label.textContent = f.name; label.textContent = f.name;
g.append(rect, label); g.append(rect, label);
// Bottom-right resize handle. Mirrors the device pattern — sits on
// top of the rect so its pointerdown wins, with stopPropagation in
// startFrameResize blocking the rect's startDrag underneath.
const FHSZ = 10;
const fHandle = svgEl("rect", {
x: f.x + f.width - FHSZ,
y: f.y + f.height - FHSZ,
width: FHSZ, height: FHSZ,
class: "frame-resize-handle",
"data-frame-id": f.id,
});
fHandle.addEventListener("pointerdown", (e) => startFrameResize(e, f.id));
g.append(fHandle);
gFrames.append(g); gFrames.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id)); rect.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id));
label.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id)); label.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id));
@@ -2385,6 +2400,51 @@ function startResize(e, deviceID) {
svg.addEventListener("pointercancel", onUp); svg.addEventListener("pointercancel", onUp);
} }
// Frame bottom-right resize gesture. Mirrors startResize for devices,
// but PATCHes /frames/:id and uses a larger minimum (frames host
// devices + IO markers + clamps, so 200×150 is the smallest useful
// canvas). Contained children stay at their absolute positions — the
// frame body drag is what moves them; resize only changes the frame's
// own bounds.
function startFrameResize(e, frameID) {
if (!state.active) return;
// Hard-stop so the rect's pointerdown doesn't also fire startDrag.
e.stopPropagation();
e.preventDefault();
const f = state.frames.find((x) => x.id === frameID);
if (!f) return;
const startWidth = f.width, startHeight = f.height;
const startWorld = svgPoint(e);
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
try { svg.setPointerCapture(e.pointerId); } catch {}
const MIN_FRAME_W = 200, MIN_FRAME_H = 150;
const onMove = (ev) => {
const p = svgPoint(ev);
f.width = Math.max(MIN_FRAME_W, startWidth + (p.x - startWorld.x));
f.height = Math.max(MIN_FRAME_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 (f.width === startWidth && f.height === startHeight) return;
try {
const updated = await patchFrame(state.active.id, f.id, {
width: f.width, height: f.height,
});
Object.assign(f, updated);
renderCanvas();
} catch (err) {
alert(`Resize failed: ${err.message}`);
}
};
svg.addEventListener("pointermove", onMove);
svg.addEventListener("pointerup", onUp);
svg.addEventListener("pointercancel", onUp);
}
// Find the topmost canvas element under (clientX, clientY) that maps to // Find the topmost canvas element under (clientX, clientY) that maps to
// a cable endpoint target. Returns { kind, id } for port / device / IO, // a cable endpoint target. Returns { kind, id } for port / device / IO,
// or null when m dropped on empty canvas. // or null when m dropped on empty canvas.

View File

@@ -183,6 +183,19 @@ body {
cursor: grab; cursor: grab;
} }
/* Frame bottom-right resize affordance. Mirrors .device-resize-handle
but uses the accent-on-frame palette so it reads as part of the frame
chrome rather than the device. */
.frame-resize-handle {
fill: rgba(0, 0, 0, 0.15);
stroke: rgba(0, 0, 0, 0.25);
stroke-width: 1;
cursor: nwse-resize;
}
.frame-resize-handle:hover {
fill: rgba(0, 0, 0, 0.3);
}
/* Stroke + fill come from the device's user-set colour, written as /* Stroke + fill come from the device's user-set colour, written as
inline style in renderCanvas — leaving them out of .device-rect so inline style in renderCanvas — leaving them out of .device-rect so
the author CSS doesn't override the inline style. */ the author CSS doesn't override the inline style. */