Compare commits

..

3 Commits

Author SHA1 Message Date
mAi
7331f334a8 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.
2026-05-17 17:21:52 +02:00
mAi
1c234f3f46 feat(ui): bottom-right resize handle on frames
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.
2026-05-17 17:19:53 +02:00
mAi
cff897978f merge: frame label = clickable drag grip
Drop pointer-events:none on .frame-label and add a pointerdown that
fires startDrag(e,'frame',f.id) — gives m a deterministic select-and-
drag grip at the top-left of every frame where devices/cables can't
occlude it.
2026-05-16 19:33:26 +02:00
2 changed files with 73 additions and 0 deletions

View File

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

View File

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