From 1c234f3f46f8562ba87e141c2a472fec1cdfe25c Mon Sep 17 00:00:00 2001 From: mAi Date: Sun, 17 May 2026 17:19:53 +0200 Subject: [PATCH] feat(ui): bottom-right resize handle on frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m: 'We should also be able to resize frames, the same way we do with devices.' Mirrors the device-resize pattern (89686d0). - 10×10 SVG handle drawn at each frame's bottom-right corner with class .frame-resize-handle + cursor: nwse-resize. Appended after the label so it sits on top of the rect and wins the pointerdown. - startFrameResize captures the pointer, stops propagation so the rect's pointerdown (= startDrag 'frame') doesn't also fire, and updates f.width / f.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 200×150 minimum during the drag (frames need more room than devices since they host devices + IO markers + clamps). - On pointerup: PATCH /api/projects/:pid/frames/:id with the new width + height. Contained children stay at their absolute positions — the frame body drag is what moves them; resize only changes the frame's own bounds, so devices/IO markers/clamps inside don't shift. --- web/static/main.js | 60 ++++++++++++++++++++++++++++++++++++++++++++ web/static/style.css | 13 ++++++++++ 2 files changed, 73 insertions(+) diff --git a/web/static/main.js b/web/static/main.js index 08834e3..c481b7a 100644 --- a/web/static/main.js +++ b/web/static/main.js @@ -480,6 +480,21 @@ function renderCanvas() { }); label.textContent = f.name; 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); rect.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); } +// 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 // a cable endpoint target. Returns { kind, id } for port / device / IO, // or null when m dropped on empty canvas. diff --git a/web/static/style.css b/web/static/style.css index 97ba34c..fbb69be 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -183,6 +183,19 @@ body { 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 inline style in renderCanvas — leaving them out of .device-rect so the author CSS doesn't override the inline style. */