Compare commits
6 Commits
mai/picass
...
mai/picass
| Author | SHA1 | Date | |
|---|---|---|---|
| 2aff5eb04d | |||
| 5c11bf33cb | |||
| 86264d1284 | |||
| b28fc0c565 | |||
| 491db730eb | |||
| 90157dfd14 |
@@ -191,10 +191,11 @@ type UnsatisfiedReq struct {
|
||||
|
||||
// ApplyTemplateResult is the response from POST /apply-template.
|
||||
type ApplyTemplateResult struct {
|
||||
DevicesAdded []Device `json:"devices_added"`
|
||||
RequirementsAdded []ConnectionRequirement `json:"requirements_added"`
|
||||
SkippedDevices []SkippedTemplateDevice `json:"skipped_devices"`
|
||||
RequirementsSkipped []SkippedTemplateReq `json:"requirements_skipped"`
|
||||
FramesAdded []Frame `json:"frames_added"`
|
||||
DevicesAdded []Device `json:"devices_added"`
|
||||
RequirementsAdded []ConnectionRequirement `json:"requirements_added"`
|
||||
SkippedDevices []SkippedTemplateDevice `json:"skipped_devices"`
|
||||
RequirementsSkipped []SkippedTemplateReq `json:"requirements_skipped"`
|
||||
}
|
||||
|
||||
type SkippedTemplateDevice struct {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -161,6 +162,7 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
|
||||
}
|
||||
|
||||
out := &ApplyTemplateResult{
|
||||
FramesAdded: []Frame{},
|
||||
DevicesAdded: []Device{},
|
||||
RequirementsAdded: []ConnectionRequirement{},
|
||||
SkippedDevices: []SkippedTemplateDevice{},
|
||||
@@ -171,8 +173,8 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
|
||||
opts.OriginX, opts.OriginY = 200, 200
|
||||
}
|
||||
|
||||
// Pull existing device names in the project so we can pre-check
|
||||
// collisions without aborting the whole transaction.
|
||||
// Pull existing device + frame names in the project so we can
|
||||
// pre-check collisions without aborting the whole transaction.
|
||||
existing, err := s.ListDevices(projectID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -181,6 +183,14 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
|
||||
for _, d := range existing {
|
||||
nameTaken[d.Name] = true
|
||||
}
|
||||
existingFrames, err := s.ListFrames(projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
frameNameTaken := map[string]bool{}
|
||||
for _, f := range existingFrames {
|
||||
frameNameTaken[f.Name] = true
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
@@ -188,6 +198,37 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Plan a uniform grid for the template's devices inside a new frame
|
||||
// named after the template. The grid drives both frame size and
|
||||
// per-device (x, y). Devices that get skipped (name collision /
|
||||
// SkipDevices) leave their grid cell empty.
|
||||
const (
|
||||
devW, devH = 100.0, 35.0
|
||||
gapX, gapY = 30.0, 50.0
|
||||
padX, padY = 32.0, 48.0 // padY larger so the frame title clears row 1
|
||||
)
|
||||
n := len(tmpl.Devices)
|
||||
cols := 1
|
||||
if n > 0 {
|
||||
cols = min(int(math.Ceil(math.Sqrt(float64(n)))), 4)
|
||||
}
|
||||
rows := 1
|
||||
if n > 0 {
|
||||
rows = (n + cols - 1) / cols
|
||||
}
|
||||
frameW := padX*2 + float64(cols)*devW + float64(cols-1)*gapX
|
||||
frameH := padY + padX + float64(rows)*devH + float64(rows-1)*gapY
|
||||
frameName := pickFrameName(tmpl.Name, frameNameTaken)
|
||||
|
||||
frame, err := createFrameTx(tx, projectID, FrameCreate{
|
||||
Name: frameName, X: opts.OriginX, Y: opts.OriginY,
|
||||
Width: frameW, Height: frameH,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seed frame %q: %w", frameName, err)
|
||||
}
|
||||
out.FramesAdded = append(out.FramesAdded, *frame)
|
||||
|
||||
// Map: template_device_id → newly-created device_id (or 0 if skipped).
|
||||
tmplToDevice := map[int64]int64{}
|
||||
|
||||
@@ -215,17 +256,22 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
|
||||
tmplToDevice[td.ID] = 0
|
||||
continue
|
||||
}
|
||||
// Lay out devices in a horizontal row near the origin, 150 px apart.
|
||||
x := opts.OriginX + float64(i)*150
|
||||
y := opts.OriginY
|
||||
// Use createDeviceTx so the port-seeding share the same transaction.
|
||||
// Grid cell (col, row) within the frame. Cell anchor is the
|
||||
// top-left of the device rect; offsets are added to the frame's
|
||||
// own (x, y) so the device sits inside the frame.
|
||||
col := i % cols
|
||||
row := i / cols
|
||||
x := frame.X + padX + float64(col)*(devW+gapX)
|
||||
y := frame.Y + padY + float64(row)*(devH+gapY)
|
||||
// Use createDeviceTx so port-seeding shares the same transaction.
|
||||
d, err := s.createDeviceTx(tx, projectID, DeviceCreate{
|
||||
Name: name,
|
||||
TypeID: &td.DeviceTypeID,
|
||||
FrameID: &frame.ID,
|
||||
X: x,
|
||||
Y: y,
|
||||
Width: 100,
|
||||
Height: 35,
|
||||
Width: devW,
|
||||
Height: devH,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("seed %s: %w", name, err)
|
||||
@@ -294,6 +340,58 @@ func (s *Store) ApplyTemplate(projectID, templateID int64, opts ApplyTemplateOpt
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pickFrameName returns a frame name that doesn't collide with anything
|
||||
// in `taken`. Tries the template name first, then "<name> 2", "<name> 3",
|
||||
// and so on.
|
||||
func pickFrameName(base string, taken map[string]bool) string {
|
||||
if !taken[base] {
|
||||
return base
|
||||
}
|
||||
for i := 2; ; i++ {
|
||||
candidate := fmt.Sprintf("%s %d", base, i)
|
||||
if !taken[candidate] {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createFrameTx inserts a frame inside the caller's transaction. Mirrors
|
||||
// the validation in CreateFrame (name + positive size) but avoids the
|
||||
// s.db.Exec call so ApplyTemplate can keep everything on the same
|
||||
// connection under MaxOpenConns(1).
|
||||
func createFrameTx(tx *sql.Tx, projectID int64, f FrameCreate) (*Frame, error) {
|
||||
name := strings.TrimSpace(f.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
}
|
||||
if f.Width <= 0 || f.Height <= 0 {
|
||||
return nil, fmt.Errorf("%w: width and height must be positive", ErrInvalidInput)
|
||||
}
|
||||
res, err := tx.Exec(
|
||||
`INSERT INTO frames (project_id, name, x, y, width, height)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
projectID, name, f.X, f.Y, f.Width, f.Height,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapWriteErr(err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
var out Frame
|
||||
var ex sql.NullString
|
||||
err = tx.QueryRow(
|
||||
`SELECT id, project_id, name, x, y, width, height, excalidraw_id, created_at, updated_at
|
||||
FROM frames WHERE id = ? AND project_id = ?`, id, projectID,
|
||||
).Scan(&out.ID, &out.ProjectID, &out.Name, &out.X, &out.Y, &out.Width, &out.Height,
|
||||
&ex, &out.CreatedAt, &out.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ex.Valid {
|
||||
out.ExcalidrawID = &ex.String
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// createDeviceTx is a tx-aware variant of CreateDevice used by
|
||||
// ApplyTemplate so seeding the template's devices + their ports stays
|
||||
// inside one atomic apply.
|
||||
|
||||
@@ -234,6 +234,76 @@ func TestApplyTemplate_HomeOffice_ThenSolve(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTemplate_CreatesFrameAndPlacesDevicesInside(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
tmpls, _ := s.ListSetupTemplates()
|
||||
var lr SetupTemplate
|
||||
for _, tm := range tmpls {
|
||||
if tm.Name == "Living Room" {
|
||||
lr = tm
|
||||
break
|
||||
}
|
||||
}
|
||||
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if len(res.FramesAdded) != 1 {
|
||||
t.Fatalf("frames added = %d, want 1", len(res.FramesAdded))
|
||||
}
|
||||
frame := res.FramesAdded[0]
|
||||
if frame.Name != "Living Room" {
|
||||
t.Errorf("frame name = %q, want %q", frame.Name, "Living Room")
|
||||
}
|
||||
for _, d := range res.DevicesAdded {
|
||||
if d.FrameID == nil || *d.FrameID != frame.ID {
|
||||
t.Errorf("device %q: frame_id = %v, want %d", d.Name, d.FrameID, frame.ID)
|
||||
}
|
||||
// Device top-left should be inside the frame rect.
|
||||
if d.X < frame.X || d.X+d.Width > frame.X+frame.Width {
|
||||
t.Errorf("device %q: x=%v width=%v outside frame [%v..%v]", d.Name, d.X, d.Width, frame.X, frame.X+frame.Width)
|
||||
}
|
||||
if d.Y < frame.Y || d.Y+d.Height > frame.Y+frame.Height {
|
||||
t.Errorf("device %q: y=%v height=%v outside frame [%v..%v]", d.Name, d.Y, d.Height, frame.Y, frame.Y+frame.Height)
|
||||
}
|
||||
}
|
||||
// No two devices share the same (X, Y) — the grid layout spreads them out.
|
||||
seen := map[[2]float64]string{}
|
||||
for _, d := range res.DevicesAdded {
|
||||
key := [2]float64{d.X, d.Y}
|
||||
if prev, ok := seen[key]; ok {
|
||||
t.Errorf("devices %q and %q share grid cell (%v, %v)", prev, d.Name, d.X, d.Y)
|
||||
}
|
||||
seen[key] = d.Name
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTemplate_FrameNameSuffixOnCollision(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
// Pre-create a frame called "Living Room" so the template's frame name collides.
|
||||
_, _ = s.CreateFrame(p.ID, FrameCreate{Name: "Living Room", X: 0, Y: 0, Width: 100, Height: 100})
|
||||
tmpls, _ := s.ListSetupTemplates()
|
||||
var lr SetupTemplate
|
||||
for _, tm := range tmpls {
|
||||
if tm.Name == "Living Room" {
|
||||
lr = tm
|
||||
break
|
||||
}
|
||||
}
|
||||
res, err := s.ApplyTemplate(p.ID, lr.ID, ApplyTemplateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if len(res.FramesAdded) != 1 {
|
||||
t.Fatalf("frames added = %d, want 1", len(res.FramesAdded))
|
||||
}
|
||||
if res.FramesAdded[0].Name != "Living Room 2" {
|
||||
t.Errorf("frame name = %q, want %q (suffixed)", res.FramesAdded[0].Name, "Living Room 2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTemplate_NameCollisionSkipped(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p, _ := s.CreateProject("LOFT", "", "")
|
||||
|
||||
@@ -293,10 +293,14 @@ function renderCanvas() {
|
||||
|
||||
for (const d of state.devices) {
|
||||
const g = svgEl("g", { "data-device-id": d.id });
|
||||
// Stroke = the user-picked colour; fill = a 12% tint of it via
|
||||
// color-mix so the device "reads" coloured without becoming garish.
|
||||
// Inline style beats the .device-rect class CSS, which is why CSS
|
||||
// no longer hard-codes stroke/fill on that class.
|
||||
const rect = svgEl("rect", {
|
||||
x: d.x, y: d.y, width: d.width, height: d.height,
|
||||
class: "device-rect svg-draggable",
|
||||
stroke: d.color,
|
||||
style: `stroke: ${d.color}; fill: color-mix(in srgb, ${d.color} 12%, white);`,
|
||||
rx: 3, ry: 3,
|
||||
});
|
||||
if (state.selection?.kind === "device" && state.selection.id === d.id) {
|
||||
@@ -1008,7 +1012,7 @@ function renderInspectorPort(body, id) {
|
||||
const ct = state.cableTypes.find((t) => t.id === prt.type_id);
|
||||
const ctColor = ct?.color || "#888";
|
||||
const ctName = ct?.name || "?";
|
||||
const currentEdge = edgeOf(dev, prt);
|
||||
const currentEdge = portEdge(prt, dev);
|
||||
|
||||
body.innerHTML = `
|
||||
<p class="section-title">Port</p>
|
||||
@@ -1046,13 +1050,26 @@ function renderInspectorPort(body, id) {
|
||||
|
||||
body.querySelector("#port-edge").addEventListener("change", async (e) => {
|
||||
if (!state.active) return;
|
||||
const edge = /** @type {HTMLSelectElement} */ (e.target).value;
|
||||
const { xOff, yOff } = edgeCenter(dev, edge);
|
||||
const newEdge = /** @type {HTMLSelectElement} */ (e.target).value;
|
||||
const oldEdge = portEdge(prt, dev);
|
||||
if (newEdge === oldEdge) return;
|
||||
// PATCH to a temp position on the new edge so portEdge() classifies
|
||||
// this port onto newEdge in the upcoming relayouts. The temp position
|
||||
// gets overwritten by relayoutEdge(newEdge); the only thing that
|
||||
// matters is that the port is unambiguously on the right edge.
|
||||
const tmp = edgeCentre(dev, newEdge);
|
||||
try {
|
||||
const updated = await patchPort(state.active.id, prt.id, {
|
||||
x_offset: xOff, y_offset: yOff,
|
||||
x_offset: tmp.xOff, y_offset: tmp.yOff,
|
||||
});
|
||||
Object.assign(prt, updated);
|
||||
// Re-space both affected edges: the one the port left and the one
|
||||
// it landed on. Order doesn't matter — they operate on disjoint
|
||||
// port sets.
|
||||
await Promise.all([
|
||||
relayoutEdge(dev.id, oldEdge),
|
||||
relayoutEdge(dev.id, newEdge),
|
||||
]);
|
||||
renderCanvas();
|
||||
} catch (ex) {
|
||||
alert(`Move port failed: ${ex.message}`);
|
||||
@@ -1062,11 +1079,15 @@ function renderInspectorPort(body, id) {
|
||||
body.querySelector("#port-delete").addEventListener("click", async () => {
|
||||
if (!state.active) return;
|
||||
if (!confirm("Delete this port?")) return;
|
||||
const wasEdge = portEdge(prt, dev);
|
||||
try {
|
||||
await deletePort(state.active.id, prt.id);
|
||||
state.ports = state.ports.filter((p) => p.id !== prt.id);
|
||||
const snap = await getSnapshot(state.active.id);
|
||||
state.cables = snap.cables || [];
|
||||
// Re-space the edge the deleted port was on so the survivors
|
||||
// shift back to even spacing.
|
||||
await relayoutEdge(dev.id, wasEdge);
|
||||
state.selection = null;
|
||||
render();
|
||||
} catch (ex) {
|
||||
@@ -1075,19 +1096,11 @@ function renderInspectorPort(body, id) {
|
||||
});
|
||||
}
|
||||
|
||||
// Which edge does a port currently sit on? Matches the convention in
|
||||
// snapToDeviceEdge: x_offset = 0 → left, = width → right, y_offset = 0
|
||||
// → top, otherwise bottom (the default).
|
||||
function edgeOf(dev, prt) {
|
||||
if (prt.x_offset <= 0) return "left";
|
||||
if (prt.x_offset >= dev.width) return "right";
|
||||
if (prt.y_offset <= 0) return "top";
|
||||
return "bottom";
|
||||
}
|
||||
|
||||
// Centre of the named edge, expressed as (x_offset, y_offset) relative
|
||||
// to the device origin.
|
||||
function edgeCenter(dev, edge) {
|
||||
// to the device origin. Used as a temp anchor when moving a port between
|
||||
// edges — the precise centre value is immediately overwritten by
|
||||
// relayoutEdge, but it has to land on the right edge.
|
||||
function edgeCentre(dev, edge) {
|
||||
switch (edge) {
|
||||
case "top": return { xOff: dev.width / 2, yOff: 0 };
|
||||
case "right": return { xOff: dev.width, yOff: dev.height / 2 };
|
||||
@@ -1567,12 +1580,79 @@ function snapToDeviceEdge(device, x, y) {
|
||||
// Clamp the perpendicular coordinate so the port sits *on* the rect.
|
||||
const localX = Math.max(0, Math.min(device.width, x - device.x));
|
||||
const localY = Math.max(0, Math.min(device.height, y - device.y));
|
||||
if (min === dxLeft) return { xOff: 0, yOff: localY, edge: "left" };
|
||||
if (min === dxRight) return { xOff: device.width, yOff: localY, edge: "right" };
|
||||
if (min === dyTop) return { xOff: localX, yOff: 0, edge: "top" };
|
||||
if (min === dxLeft) return { xOff: 0, yOff: localY, edge: "left" };
|
||||
if (min === dxRight) return { xOff: device.width, yOff: localY, edge: "right" };
|
||||
if (min === dyTop) return { xOff: localX, yOff: 0, edge: "top" };
|
||||
return { xOff: localX, yOff: device.height, edge: "bottom" };
|
||||
}
|
||||
|
||||
// Which edge does a given port currently sit on? Snaps the port's
|
||||
// existing (x_offset, y_offset) to the nearest of the four edges using
|
||||
// the same distance heuristic as snapToDeviceEdge.
|
||||
function portEdge(port, device) {
|
||||
const dL = port.x_offset;
|
||||
const dR = device.width - port.x_offset;
|
||||
const dT = port.y_offset;
|
||||
const dB = device.height - port.y_offset;
|
||||
const min = Math.min(dL, dR, dT, dB);
|
||||
if (min === dL) return "left";
|
||||
if (min === dR) return "right";
|
||||
if (min === dT) return "top";
|
||||
return "bottom";
|
||||
}
|
||||
|
||||
// Even-spacing layout invariant for ports on a device edge: m wants
|
||||
// every port lined up on its edge with no overlap. After any change
|
||||
// to the set of ports on an edge (add / move / delete), recompute the
|
||||
// offsets so that for N ports they sit at relative positions
|
||||
// i/(N+1) along the edge for i=1..N.
|
||||
//
|
||||
// Sort key preserves m's intent: top/bottom by current x_offset
|
||||
// (left→right), left/right by current y_offset (top→bottom). For a
|
||||
// freshly-placed port, that's the click position projected onto the
|
||||
// edge, so the port keeps its "I dropped it roughly here" rank.
|
||||
//
|
||||
// PATCHes only the ports whose offsets actually change, and updates
|
||||
// state.ports in place. Returns once every PATCH resolves.
|
||||
async function relayoutEdge(deviceID, edge) {
|
||||
if (!state.active) return;
|
||||
const dev = state.devices.find((d) => d.id === deviceID);
|
||||
if (!dev) return;
|
||||
const isHorizontal = edge === "top" || edge === "bottom";
|
||||
const axis = isHorizontal ? dev.width : dev.height;
|
||||
const peers = state.ports
|
||||
.filter((p) => p.device_id === deviceID && portEdge(p, dev) === edge)
|
||||
.slice()
|
||||
.sort((a, b) =>
|
||||
isHorizontal ? a.x_offset - b.x_offset : a.y_offset - b.y_offset);
|
||||
const n = peers.length;
|
||||
if (n === 0) return;
|
||||
const patches = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const parallel = axis * (i + 1) / (n + 1);
|
||||
let xOff, yOff;
|
||||
switch (edge) {
|
||||
case "top": xOff = parallel; yOff = 0; break;
|
||||
case "bottom": xOff = parallel; yOff = dev.height; break;
|
||||
case "left": xOff = 0; yOff = parallel; break;
|
||||
case "right": xOff = dev.width; yOff = parallel; break;
|
||||
}
|
||||
const p = peers[i];
|
||||
if (p.x_offset === xOff && p.y_offset === yOff) continue;
|
||||
p.x_offset = xOff;
|
||||
p.y_offset = yOff;
|
||||
patches.push(patchPort(state.active.id, p.id, { x_offset: xOff, y_offset: yOff })
|
||||
.then((updated) => Object.assign(p, updated)));
|
||||
}
|
||||
if (patches.length) {
|
||||
try {
|
||||
await Promise.all(patches);
|
||||
} catch (err) {
|
||||
alert(`Re-layout failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Port-click flow:
|
||||
* - A cable draw is in progress (cableDrawFromPortID set):
|
||||
* same port → cancel; another port → finish the cable.
|
||||
@@ -1697,6 +1777,13 @@ async function placePortAt(p) {
|
||||
y_offset: snap.yOff,
|
||||
});
|
||||
state.ports.push(port);
|
||||
// Re-layout all ports on this edge so the new one + existing ones
|
||||
// are evenly spaced — m's invariant: never let two ports stack.
|
||||
await relayoutEdge(did, snap.edge);
|
||||
// Select the freshly-placed port so the inspector switches to the
|
||||
// port panel (edge dropdown / label / delete) and the .selected halo
|
||||
// marks it.
|
||||
state.selection = { kind: "port", id: port.id };
|
||||
armTool(null);
|
||||
render();
|
||||
} catch (e) {
|
||||
@@ -1821,7 +1908,13 @@ function startDrag(e, kind, id) {
|
||||
}
|
||||
}
|
||||
|
||||
e.currentTarget.classList.add("dragging");
|
||||
// Capture the rect element NOW: by the time onUp fires async, the
|
||||
// browser has nulled out e.currentTarget on the pointerdown event,
|
||||
// so `e.currentTarget.classList.remove("dragging")` would throw
|
||||
// "Cannot read properties of null". Sherlock surfaced this from the
|
||||
// click-only path that pageerror-spammed every device click.
|
||||
const dragTarget = /** @type {Element} */ (e.currentTarget);
|
||||
dragTarget.classList.add("dragging");
|
||||
svg.setPointerCapture(e.pointerId);
|
||||
|
||||
let dragged = false;
|
||||
@@ -1843,7 +1936,7 @@ function startDrag(e, kind, id) {
|
||||
svg.removeEventListener("pointermove", onMove);
|
||||
svg.removeEventListener("pointerup", onUp);
|
||||
svg.releasePointerCapture(e.pointerId);
|
||||
e.currentTarget.classList.remove("dragging");
|
||||
dragTarget.classList.remove("dragging");
|
||||
|
||||
if (!dragged) { render(); return; } // click only — re-render to apply selection halo
|
||||
if (!state.active) return;
|
||||
|
||||
@@ -183,9 +183,10 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
.device-rect {
|
||||
fill: #fff;
|
||||
stroke: var(--text);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.device-rect.selected { stroke-width: 3; }
|
||||
|
||||
Reference in New Issue
Block a user