merge: slice 3 — IO markers + cable-type editing UI

picasso shipped (3 commits @ a3f0586):
- internal/db/io_markers.go: project-scoped CRUD, cross-project FK rejection
- internal/server/io_markers.go: handlers under /api/projects/:pid/io-markers
- web/static: +IO tool with click-place, diamond rendering (SVG polygon),
  drag, inspector for IO + cable-type, interactive legend with native
  colour-picker + delete-blocked-on-use, '+ Type' modal, 'used by N
  cables' counter

37 store tests green with -race.
This commit is contained in:
mAi
2026-05-16 00:13:53 +02:00
9 changed files with 678 additions and 26 deletions

180
internal/db/io_markers.go Normal file
View File

@@ -0,0 +1,180 @@
package db
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// IOMarker is a wall-outlet terminator inside a project. Mostly Power
// by convention; the schema doesn't enforce it.
type IOMarker struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
FrameID *int64 `json:"frame_id"`
Label string `json:"label"`
X float64 `json:"x"`
Y float64 `json:"y"`
ExcalidrawID *string `json:"excalidraw_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// IOMarkerCreate is the create-shape.
type IOMarkerCreate struct {
FrameID *int64
Label string
X float64
Y float64
}
// IOMarkerUpdate is the partial-update shape. project_id deliberately not
// settable; frame_id uses the same tri-state shape as DeviceUpdate.FrameID.
type IOMarkerUpdate struct {
Label *string
FrameID FrameRef
X *float64
Y *float64
}
// CreateIOMarker inserts a new IO marker. If frame_id is set, it must
// reference a frame in the same project.
func (s *Store) CreateIOMarker(projectID int64, m IOMarkerCreate) (*IOMarker, error) {
label := strings.TrimSpace(m.Label)
if label == "" {
label = "IO"
}
if _, err := s.GetProject(projectID); err != nil {
return nil, err
}
if m.FrameID != nil {
if _, err := s.GetFrame(projectID, *m.FrameID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *m.FrameID, projectID)
}
return nil, err
}
}
res, err := s.db.Exec(
`INSERT INTO io_markers (project_id, frame_id, label, x, y)
VALUES (?, ?, ?, ?, ?)`,
projectID, nullableInt64(m.FrameID), label, m.X, m.Y,
)
if err != nil {
return nil, mapWriteErr(err)
}
id, _ := res.LastInsertId()
return s.GetIOMarker(projectID, id)
}
// GetIOMarker loads an IO marker, project-scoped.
func (s *Store) GetIOMarker(projectID, id int64) (*IOMarker, error) {
var m IOMarker
var frame sql.NullInt64
var ex sql.NullString
err := s.db.QueryRow(
`SELECT id, project_id, frame_id, label, x, y, excalidraw_id, created_at, updated_at
FROM io_markers WHERE id = ? AND project_id = ?`, id, projectID,
).Scan(&m.ID, &m.ProjectID, &frame, &m.Label, &m.X, &m.Y, &ex, &m.CreatedAt, &m.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if frame.Valid {
v := frame.Int64
m.FrameID = &v
}
if ex.Valid {
m.ExcalidrawID = &ex.String
}
return &m, nil
}
// ListIOMarkers returns every IO marker in a project, ordered by creation.
func (s *Store) ListIOMarkers(projectID int64) ([]IOMarker, error) {
rows, err := s.db.Query(
`SELECT id, project_id, frame_id, label, x, y, excalidraw_id, created_at, updated_at
FROM io_markers WHERE project_id = ? ORDER BY created_at, id`, projectID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []IOMarker{}
for rows.Next() {
var m IOMarker
var frame sql.NullInt64
var ex sql.NullString
if err := rows.Scan(&m.ID, &m.ProjectID, &frame, &m.Label, &m.X, &m.Y,
&ex, &m.CreatedAt, &m.UpdatedAt); err != nil {
return nil, err
}
if frame.Valid {
v := frame.Int64
m.FrameID = &v
}
if ex.Valid {
m.ExcalidrawID = &ex.String
}
out = append(out, m)
}
return out, rows.Err()
}
// UpdateIOMarker applies a partial update. project_id is locked; frame_id
// tri-state mirrors DeviceUpdate.FrameID.
func (s *Store) UpdateIOMarker(projectID, id int64, u IOMarkerUpdate) (*IOMarker, error) {
cur, err := s.GetIOMarker(projectID, id)
if err != nil {
return nil, err
}
if u.Label != nil {
v := strings.TrimSpace(*u.Label)
if v == "" {
return nil, fmt.Errorf("%w: label cannot be empty", ErrInvalidInput)
}
cur.Label = v
}
if u.X != nil {
cur.X = *u.X
}
if u.Y != nil {
cur.Y = *u.Y
}
if u.FrameID.Set {
if u.FrameID.ID != nil {
if _, err := s.GetFrame(projectID, *u.FrameID.ID); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("%w: frame_id %d not in project %d", ErrInvalidInput, *u.FrameID.ID, projectID)
}
return nil, err
}
}
cur.FrameID = u.FrameID.ID
}
if _, err := s.db.Exec(
`UPDATE io_markers
SET frame_id = ?, label = ?, x = ?, y = ?, updated_at = datetime('now')
WHERE id = ? AND project_id = ?`,
nullableInt64(cur.FrameID), cur.Label, cur.X, cur.Y, id, projectID,
); err != nil {
return nil, mapWriteErr(err)
}
return s.GetIOMarker(projectID, id)
}
// DeleteIOMarker removes an IO marker from a project.
func (s *Store) DeleteIOMarker(projectID, id int64) error {
if _, err := s.GetIOMarker(projectID, id); err != nil {
return err
}
if _, err := s.db.Exec(
`DELETE FROM io_markers WHERE id = ? AND project_id = ?`, id, projectID,
); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,113 @@
package db
import (
"errors"
"testing"
)
func TestCreateIOMarker_DefaultsLabel(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
m, err := s.CreateIOMarker(p.ID, IOMarkerCreate{X: 10, Y: 20})
if err != nil {
t.Fatalf("create: %v", err)
}
if m.Label != "IO" {
t.Errorf("default label = %q, want IO", m.Label)
}
if m.FrameID != nil {
t.Errorf("frame_id = %v, want nil", m.FrameID)
}
}
func TestCreateIOMarker_CustomLabel(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
m, err := s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "Wall A", X: 0, Y: 0})
if err != nil {
t.Fatalf("create: %v", err)
}
if m.Label != "Wall A" {
t.Errorf("label = %q, want Wall A", m.Label)
}
}
func TestCreateIOMarker_CrossProjectFrameRejected(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
f2, _ := s.CreateFrame(p2.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
_, err := s.CreateIOMarker(p1.ID, IOMarkerCreate{FrameID: &f2.ID, X: 0, Y: 0})
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("cross-project frame_id should ErrInvalidInput; got %v", err)
}
}
func TestGetIOMarker_WrongProjectIsNotFound(t *testing.T) {
s := newTestStore(t)
p1, _ := s.CreateProject("LOFT", "", "")
p2, _ := s.CreateProject("OFFICE", "", "")
m, _ := s.CreateIOMarker(p1.ID, IOMarkerCreate{X: 0, Y: 0})
if _, err := s.GetIOMarker(p2.ID, m.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("cross-project GetIOMarker should be ErrNotFound; got %v", err)
}
}
func TestUpdateIOMarker_FrameIDTriState(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 100, Height: 50})
m, _ := s.CreateIOMarker(p.ID, IOMarkerCreate{FrameID: &f.ID, X: 0, Y: 0})
// Leave alone — passing a different X must not clear frame_id.
nx := 99.0
u1, _ := s.UpdateIOMarker(p.ID, m.ID, IOMarkerUpdate{X: &nx})
if u1.FrameID == nil || *u1.FrameID != f.ID {
t.Errorf("frame_id should still be set (Set=false); got %v", u1.FrameID)
}
// Clear.
u2, _ := s.UpdateIOMarker(p.ID, m.ID, IOMarkerUpdate{FrameID: FrameRef{Set: true, ID: nil}})
if u2.FrameID != nil {
t.Errorf("frame_id should be nil after clear; got %v", *u2.FrameID)
}
}
func TestDeleteFrame_SetsIOMarkerFrameIDToNull(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
f, _ := s.CreateFrame(p.ID, FrameCreate{Name: "desk", Width: 800, Height: 600})
m, _ := s.CreateIOMarker(p.ID, IOMarkerCreate{FrameID: &f.ID, X: 10, Y: 20})
if m.FrameID == nil {
t.Fatalf("pre-condition: io marker should have frame_id")
}
if err := s.DeleteFrame(p.ID, f.ID); err != nil {
t.Fatalf("delete frame: %v", err)
}
m2, _ := s.GetIOMarker(p.ID, m.ID)
if m2.FrameID != nil {
t.Errorf("io marker frame_id post-delete = %v, want nil (SET NULL)", m2.FrameID)
}
}
func TestSnapshot_PopulatesIOMarkers(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
_, _ = s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "Wall A", X: 10, Y: 20})
_, _ = s.CreateIOMarker(p.ID, IOMarkerCreate{Label: "UPS rear", X: 100, Y: 200})
snap, err := s.Snapshot(p.ID)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
if len(snap.IOMarkers) != 2 {
t.Errorf("io_markers len = %d, want 2", len(snap.IOMarkers))
}
}
func TestDeleteIOMarker_NotFound(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject("LOFT", "", "")
if err := s.DeleteIOMarker(p.ID, 999); !errors.Is(err, ErrNotFound) {
t.Errorf("got %v, want ErrNotFound", err)
}
}

View File

@@ -58,7 +58,7 @@ type Snapshot struct {
Devices []Device `json:"devices"`
Ports []any `json:"ports"`
Cables []any `json:"cables"`
IOMarkers []any `json:"io_markers"`
IOMarkers []IOMarker `json:"io_markers"`
Bundles []any `json:"bundles"`
CableTypes []CableType `json:"cable_types"`
}

View File

@@ -167,13 +167,17 @@ func (s *Store) Snapshot(id int64) (*Snapshot, error) {
if err != nil {
return nil, err
}
ios, err := s.ListIOMarkers(id)
if err != nil {
return nil, err
}
return &Snapshot{
Project: *p,
Frames: frames,
Devices: devices,
Ports: []any{},
Cables: []any{},
IOMarkers: []any{},
IOMarkers: ios,
Bundles: []any{},
CableTypes: types,
}, nil

View File

@@ -0,0 +1,109 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/mcables/internal/db"
)
type ioMarkerCreate struct {
FrameID *int64 `json:"frame_id,omitempty"`
Label string `json:"label,omitempty"`
X float64 `json:"x"`
Y float64 `json:"y"`
}
// ioMarkerPatch mirrors devicePatch's frame_id tri-state — see
// devicePatch + parseFrameRef in frames_devices.go for the wire format.
type ioMarkerPatch struct {
Label *string `json:"label,omitempty"`
FrameID json.RawMessage `json:"frame_id,omitempty"`
X *float64 `json:"x,omitempty"`
Y *float64 `json:"y,omitempty"`
}
func (h *handlers) listIOMarkers(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
ms, err := h.store.ListIOMarkers(pid)
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, ms)
}
func (h *handlers) createIOMarker(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
var body ioMarkerCreate
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
m, err := h.store.CreateIOMarker(pid, db.IOMarkerCreate{
FrameID: body.FrameID, Label: body.Label, X: body.X, Y: body.Y,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusCreated, m)
}
func (h *handlers) patchIOMarker(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
var body ioMarkerPatch
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), nil)
return
}
ref, err := parseFrameRef(body.FrameID)
if err != nil {
writeError(w, errors.Join(db.ErrInvalidInput, err), "frame_id must be an integer or null")
return
}
m, err := h.store.UpdateIOMarker(pid, id, db.IOMarkerUpdate{
Label: body.Label, FrameID: ref, X: body.X, Y: body.Y,
})
if err != nil {
writeError(w, err, nil)
return
}
writeJSON(w, http.StatusOK, m)
}
func (h *handlers) deleteIOMarker(w http.ResponseWriter, r *http.Request) {
pid, ok := parseInt64Path(r, "pid")
if !ok {
writeError(w, db.ErrInvalidInput, "pid must be a positive integer")
return
}
id, ok := parseInt64Path(r, "id")
if !ok {
writeError(w, db.ErrInvalidInput, "id must be a positive integer")
return
}
if err := h.store.DeleteIOMarker(pid, id); err != nil {
writeError(w, err, nil)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -45,6 +45,12 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
mux.HandleFunc("PATCH /api/projects/{pid}/devices/{id}", h.patchDevice)
mux.HandleFunc("DELETE /api/projects/{pid}/devices/{id}", h.deleteDevice)
// IO markers (project-scoped) — wall-outlet terminators
mux.HandleFunc("GET /api/projects/{pid}/io-markers", h.listIOMarkers)
mux.HandleFunc("POST /api/projects/{pid}/io-markers", h.createIOMarker)
mux.HandleFunc("PATCH /api/projects/{pid}/io-markers/{id}", h.patchIOMarker)
mux.HandleFunc("DELETE /api/projects/{pid}/io-markers/{id}", h.deleteIOMarker)
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified
// the file server already emits — without this, browsers cache aggressively

View File

@@ -37,8 +37,8 @@
<ul class="tool-list">
<li><button type="button" id="tool-frame" class="btn btn-tiny" data-tool="frame">+ Frame</button></li>
<li><button type="button" id="tool-device" class="btn btn-tiny" data-tool="device">+ Device</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 4">+ IO</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 3">Draw cable</button></li>
<li><button type="button" id="tool-io" class="btn btn-tiny" data-tool="io">+ IO</button></li>
<li><button type="button" class="btn btn-tiny" disabled title="Slice 7">Draw cable</button></li>
</ul>
</section>
</aside>

View File

@@ -14,10 +14,13 @@
* @typedef {{ id: number, project_id: number, frame_id: number|null,
* name: string, color: string,
* x: number, y: number, width: number, height: number }} Device
* @typedef {{ id: number, project_id: number, frame_id: number|null,
* label: string, x: number, y: number }} IOMarker
*/
const API = "/api";
const SVG_NS = "http://www.w3.org/2000/svg";
const IO_SIZE = 30; // diamond bounding-box side (the rotated rect's width/height)
const state = {
/** @type {Project[]} */ projects: [],
@@ -25,10 +28,11 @@ const state = {
/** @type {Project | null} */ active: null,
/** @type {Frame[]} */ frames: [],
/** @type {Device[]} */ devices: [],
/** @type {IOMarker[]} */ ioMarkers: [],
activeTypeId: /** @type {number|null} */ (null),
/** "frame" | "device" | null */
/** "frame" | "device" | "io" | null */
tool: /** @type {string|null} */ (null),
/** @type {{kind: "frame"|"device", id: number} | null} */ selection: null,
/** @type {{kind: "frame"|"device"|"io"|"cable_type", id: number} | null} */ selection: null,
};
// ---------- API client ---------- //
@@ -70,6 +74,10 @@ const createDevice = (pid, body) => api("POST", `/projects/${pid}/devices`, bo
const patchDevice = (pid, id, body) => api("PATCH", `/projects/${pid}/devices/${id}`, body);
const deleteDevice = (pid, id) => api("DELETE", `/projects/${pid}/devices/${id}`);
const createIOMarker = (pid, body) => api("POST", `/projects/${pid}/io-markers`, body);
const patchIOMarker = (pid, id, body) => api("PATCH", `/projects/${pid}/io-markers/${id}`, body);
const deleteIOMarker = (pid, id) => api("DELETE", `/projects/${pid}/io-markers/${id}`);
// ---------- DOM helpers ---------- //
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
@@ -164,8 +172,13 @@ function renderLegend() {
e.stopPropagation();
return;
}
// Click toggles activeTypeId AND moves the inspector to show the
// cable type's details. If m clicks the already-active type the
// active is cleared but the inspector still shows it (so m can
// edit name/colour without an active draw mode getting in the way).
state.activeTypeId = state.activeTypeId === t.id ? null : t.id;
renderLegend();
state.selection = { kind: "cable_type", id: t.id };
render();
});
ul.append(li);
}
@@ -191,8 +204,10 @@ function renderEmptyHint() {
function renderCanvas() {
const gFrames = $("#canvas-frames");
const gDevices = $("#canvas-devices");
const gIO = $("#canvas-io");
gFrames.innerHTML = "";
gDevices.innerHTML = "";
gIO.innerHTML = "";
for (const f of state.frames) {
const g = svgEl("g", { "data-frame-id": f.id });
@@ -234,6 +249,31 @@ function renderCanvas() {
gDevices.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "device", d.id));
}
for (const m of state.ioMarkers) {
const g = svgEl("g", { "data-io-id": m.id });
// Diamond = a square rotated 45° around its centre. Using a <rect>
// with rotate(45 cx cy) is the easiest hit-shape that still respects
// x/y as the rotated bounding box.
const cx = m.x + IO_SIZE / 2;
const cy = m.y + IO_SIZE / 2;
const rect = svgEl("rect", {
x: m.x, y: m.y, width: IO_SIZE, height: IO_SIZE,
class: "io-marker svg-draggable",
transform: `rotate(45 ${cx} ${cy})`,
});
if (state.selection?.kind === "io" && state.selection.id === m.id) {
rect.classList.add("selected");
}
const label = svgEl("text", {
x: cx, y: cy + IO_SIZE * 0.85,
class: "io-marker-label",
});
label.textContent = m.label;
g.append(rect, label);
gIO.append(g);
rect.addEventListener("pointerdown", (e) => startDrag(e, "io", m.id));
}
}
function renderInspector() {
@@ -242,10 +282,12 @@ function renderInspector() {
body.innerHTML = `<p class="muted">Nothing selected.</p>`;
return;
}
if (state.selection.kind === "frame") {
renderInspectorFrame(body, state.selection.id);
} else {
renderInspectorDevice(body, state.selection.id);
switch (state.selection.kind) {
case "frame": return renderInspectorFrame(body, state.selection.id);
case "device": return renderInspectorDevice(body, state.selection.id);
case "io": return renderInspectorIO(body, state.selection.id);
case "cable_type": return renderInspectorCableType(body, state.selection.id);
default: body.innerHTML = `<p class="muted">Nothing selected.</p>`;
}
}
@@ -253,6 +295,7 @@ function renderInspectorFrame(body, id) {
const f = state.frames.find((x) => x.id === id);
if (!f) { body.innerHTML = ""; return; }
const deviceCount = state.devices.filter((d) => d.frame_id === f.id).length;
const ioCount = state.ioMarkers.filter((m) => m.frame_id === f.id).length;
body.innerHTML = `
<p class="section-title">Frame</p>
<label class="field">
@@ -265,6 +308,7 @@ function renderInspectorFrame(body, id) {
<dt>w</dt><dd id="frm-w"></dd>
<dt>h</dt><dd id="frm-h"></dd>
<dt>devices</dt><dd id="frm-count"></dd>
<dt>IO</dt><dd id="frm-io-count"></dd>
</dl>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="frm-delete">Delete frame</button>
@@ -276,6 +320,7 @@ function renderInspectorFrame(body, id) {
body.querySelector("#frm-w").textContent = f.width.toFixed(0);
body.querySelector("#frm-h").textContent = f.height.toFixed(0);
body.querySelector("#frm-count").textContent = String(deviceCount);
body.querySelector("#frm-io-count").textContent = String(ioCount);
bindDebouncedRename(body.querySelector("#frm-name"), async (name) => {
if (!state.active) return;
@@ -286,9 +331,10 @@ function renderInspectorFrame(body, id) {
body.querySelector("#frm-delete").addEventListener("click", () => {
if (!state.active) return;
if (!confirm(`Delete frame "${f.name}"? Its devices stay but lose their frame.`)) return;
if (!confirm(`Delete frame "${f.name}"? Its devices and IO markers stay but lose their frame.`)) return;
deleteFrame(state.active.id, f.id).then(() => {
state.frames = state.frames.filter((x) => x.id !== f.id);
for (const m of state.ioMarkers) if (m.frame_id === f.id) m.frame_id = null;
for (const d of state.devices) if (d.frame_id === f.id) d.frame_id = null;
state.selection = null;
render();
@@ -361,6 +407,120 @@ function renderInspectorDevice(body, id) {
});
}
function renderInspectorIO(body, id) {
const m = state.ioMarkers.find((x) => x.id === id);
if (!m) { body.innerHTML = ""; return; }
const frame = m.frame_id ? state.frames.find((f) => f.id === m.frame_id) : null;
body.innerHTML = `
<p class="section-title">IO marker</p>
<label class="field">
<span>Label</span>
<input class="inline-input" id="io-label" value="" />
</label>
<dl>
<dt>x</dt><dd id="io-x"></dd>
<dt>y</dt><dd id="io-y"></dd>
<dt>frame</dt><dd id="io-frame"></dd>
</dl>
<p class="muted" style="font-size:12px">
Wall-outlet terminator. Power-by-convention; a future cable terminating
here means "plugged into a socket outside the diagram".
</p>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="io-delete">Delete</button>
</div>
`;
body.querySelector("#io-label").value = m.label;
body.querySelector("#io-x").textContent = m.x.toFixed(0);
body.querySelector("#io-y").textContent = m.y.toFixed(0);
body.querySelector("#io-frame").textContent = frame ? frame.name : "—";
bindDebouncedRename(body.querySelector("#io-label"), async (label) => {
if (!state.active) return;
const updated = await patchIOMarker(state.active.id, m.id, { label });
Object.assign(m, updated);
renderCanvas();
});
body.querySelector("#io-delete").addEventListener("click", () => {
if (!state.active) return;
if (!confirm(`Delete IO marker "${m.label}"?`)) return;
deleteIOMarker(state.active.id, m.id).then(() => {
state.ioMarkers = state.ioMarkers.filter((x) => x.id !== m.id);
state.selection = null;
render();
}).catch((e) => alert(`Delete failed: ${e.message}`));
});
}
function renderInspectorCableType(body, id) {
const t = state.cableTypes.find((x) => x.id === id);
if (!t) { body.innerHTML = ""; return; }
// The "used by N cables" counter is purely informational in slice 3.
// Slice 7+ will populate state.cables; until then we surface 0.
const usedBy = 0;
const banner = `
<p class="banner" style="margin: 0 0 12px 0">
Cable types are shared across all projects. Renaming or recolouring
affects every project.
</p>
`;
body.innerHTML = `
<p class="section-title">Cable type</p>
${banner}
<label class="field">
<span>Name</span>
<input class="inline-input" id="ct-name" value="" />
</label>
<label class="field">
<span>Colour</span>
<input type="color" class="inline-input" id="ct-color" />
</label>
<dl>
<dt>used by</dt><dd id="ct-used"></dd>
</dl>
<div class="inspector-actions">
<button type="button" class="btn btn-danger btn-tiny" id="ct-delete">Delete</button>
</div>
`;
body.querySelector("#ct-name").value = t.name;
body.querySelector("#ct-color").value = t.color;
body.querySelector("#ct-used").textContent = `${usedBy} cable${usedBy === 1 ? "" : "s"}`;
bindDebouncedRename(body.querySelector("#ct-name"), async (name) => {
const updated = await patchCableType(t.id, { name });
Object.assign(t, updated);
render();
});
body.querySelector("#ct-color").addEventListener("change", async (e) => {
const color = /** @type {HTMLInputElement} */ (e.target).value;
try {
const updated = await patchCableType(t.id, { color });
Object.assign(t, updated);
render();
} catch (err) {
alert(`Colour update failed: ${err.message}`);
}
});
body.querySelector("#ct-delete").addEventListener("click", async () => {
if (!confirm(`Delete cable type "${t.name}"? Blocked if any cable uses it.`)) return;
try {
await deleteCableType(t.id);
state.cableTypes = await listCableTypes();
if (state.activeTypeId === t.id) state.activeTypeId = null;
state.selection = null;
render();
} catch (err) {
const n = err.details?.in_use_by_cables;
alert(n != null
? `Cannot delete "${t.name}" — in use by ${n} cable${n === 1 ? "" : "s"}.`
: `Delete failed: ${err.message}`);
}
});
}
function bindDebouncedRename(input, persist) {
let timer = null;
input.addEventListener("input", () => {
@@ -395,6 +555,7 @@ async function activateProject(id) {
state.active = null;
state.frames = [];
state.devices = [];
state.ioMarkers = [];
state.selection = null;
setActiveInURL(null);
render();
@@ -405,6 +566,7 @@ async function activateProject(id) {
state.active = snap.project;
state.frames = snap.frames || [];
state.devices = snap.devices || [];
state.ioMarkers = snap.io_markers || [];
state.cableTypes = snap.cable_types || [];
state.selection = null;
setActiveInURL(id);
@@ -414,6 +576,7 @@ async function activateProject(id) {
state.active = null;
state.frames = [];
state.devices = [];
state.ioMarkers = [];
setActiveInURL(null);
render();
} else {
@@ -447,6 +610,7 @@ function bindTools() {
if (e.key === "Escape") { armTool(null); cancelInlineNamer(); state.selection = null; render(); }
else if (e.key === "f" || e.key === "F") armTool("frame");
else if (e.key === "d" || e.key === "D") armTool("device");
else if (e.key === "i" || e.key === "I") armTool("io");
});
// Canvas-level pointerdown handles tool activation + selection clearing.
@@ -483,10 +647,15 @@ function onCanvasPointerDown(e) {
placeDeviceAt(p);
return;
}
if (state.tool === "io") {
e.preventDefault();
placeIOMarkerAt(p);
return;
}
// No tool armed: clicks that started on a device/frame go to their
// No tool armed: clicks that started on a device/frame/io go to their
// own handlers (drag / select). Leave them alone.
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id]")) return;
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id], [data-io-id]")) return;
// Plain canvas click = clear selection.
if (state.selection) { state.selection = null; render(); }
@@ -566,6 +735,30 @@ async function placeDeviceAt(p) {
}
}
async function placeIOMarkerAt(p) {
if (!state.active) return;
armTool(null);
const x = p.x - IO_SIZE / 2;
const y = p.y - IO_SIZE / 2;
// Label is optional; a blank prompt commits with the default "IO"
// (server-side fallback in CreateIOMarker). Esc cancels.
const label = await promptInline("Outlet label (Enter for 'IO')", p.x, p.y - IO_SIZE);
if (label === null || !state.active) return;
const frame = frameAt(p.x, p.y);
try {
const m = await createIOMarker(state.active.id, {
label: label || undefined,
x, y,
frame_id: frame ? frame.id : undefined,
});
state.ioMarkers.push(m);
state.selection = { kind: "io", id: m.id };
render();
} catch (err) {
alert(`Create IO marker failed: ${err.message}`);
}
}
// ---------- inline namer (foreignObject overlay) ---------- //
let activeNamer = /** @type {SVGForeignObjectElement|null} */ (null);
@@ -625,23 +818,30 @@ function startDrag(e, kind, id) {
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
const start = svgPoint(e);
/** @type {Frame|Device|undefined} */
const obj = kind === "frame"
? state.frames.find((f) => f.id === id)
: state.devices.find((d) => d.id === id);
/** @type {Frame|Device|IOMarker|undefined} */
let obj;
if (kind === "frame") obj = state.frames.find((f) => f.id === id);
else if (kind === "device") obj = state.devices.find((d) => d.id === id);
else if (kind === "io") obj = state.ioMarkers.find((m) => m.id === id);
if (!obj) return;
const startX = obj.x;
const startY = obj.y;
// For frame drags, remember the contained devices + their offsets so
// they follow the frame visually + persist on release.
// For frame drags, remember the contained devices + IO markers + their
// offsets so they follow the frame visually + persist on release.
let trackedDevices = /** @type {{d: Device, sx: number, sy: number}[]} */ ([]);
let trackedIOs = /** @type {{m: IOMarker, sx: number, sy: number}[]} */ ([]);
if (kind === "frame") {
for (const d of state.devices) {
if (d.frame_id === obj.id) {
trackedDevices.push({ d, sx: d.x, sy: d.y });
}
}
for (const m of state.ioMarkers) {
if (m.frame_id === obj.id) {
trackedIOs.push({ m, sx: m.x, sy: m.y });
}
}
}
e.currentTarget.classList.add("dragging");
@@ -658,6 +858,7 @@ function startDrag(e, kind, id) {
obj.y = startY + dy;
if (kind === "frame") {
for (const t of trackedDevices) { t.d.x = t.sx + dx; t.d.y = t.sy + dy; }
for (const t of trackedIOs) { t.m.x = t.sx + dx; t.m.y = t.sy + dy; }
}
renderCanvas();
};
@@ -674,12 +875,14 @@ function startDrag(e, kind, id) {
if (kind === "frame") {
const f = /** @type {Frame} */ (obj);
await patchFrame(state.active.id, f.id, { x: f.x, y: f.y });
// Persist contained devices too.
await Promise.all(
trackedDevices.map((t) =>
// Persist contained devices + IO markers too.
await Promise.all([
...trackedDevices.map((t) =>
patchDevice(state.active.id, t.d.id, { x: t.d.x, y: t.d.y })),
);
} else {
...trackedIOs.map((t) =>
patchIOMarker(state.active.id, t.m.id, { x: t.m.x, y: t.m.y })),
]);
} else if (kind === "device") {
const d = /** @type {Device} */ (obj);
// Recompute frame_id from drop point (centre of device).
const cx = d.x + d.width / 2;
@@ -692,6 +895,18 @@ function startDrag(e, kind, id) {
d.frame_id = newFrameID;
}
await patchDevice(state.active.id, d.id, patchBody);
} else /* io */ {
const m = /** @type {IOMarker} */ (obj);
const cx = m.x + IO_SIZE / 2;
const cy = m.y + IO_SIZE / 2;
const targetFrame = frameAt(cx, cy);
const newFrameID = targetFrame ? targetFrame.id : null;
const patchBody = { x: m.x, y: m.y };
if ((m.frame_id ?? null) !== newFrameID) {
patchBody.frame_id = newFrameID;
m.frame_id = newFrameID;
}
await patchIOMarker(state.active.id, m.id, patchBody);
}
} catch (err) {
alert(`Save failed: ${err.message}`);

View File

@@ -211,7 +211,32 @@ body {
.canvas-wrap.tool-frame #canvas,
.canvas-wrap.tool-frame #canvas *,
.canvas-wrap.tool-device #canvas,
.canvas-wrap.tool-device #canvas * { cursor: crosshair !important; }
.canvas-wrap.tool-device #canvas *,
.canvas-wrap.tool-io #canvas,
.canvas-wrap.tool-io #canvas * { cursor: crosshair !important; }
/* IO markers — diamonds. Power-by-convention, so the default fill is
the Power cable_type colour (#e03131). Rotated 45° rect is the
easiest way to draw a diamond that still hit-tests at the rotated
bounds (a <polygon> would also work; rect-with-rotate keeps the
same DOM shape as device/frame so the drag helpers reuse). */
.io-marker {
fill: var(--danger);
fill-opacity: 0.18;
stroke: var(--danger);
stroke-width: 1.5;
}
.io-marker.selected,
.io-marker:hover { stroke-width: 2.5; }
.io-marker-label {
fill: var(--danger);
font-size: 11px;
font-weight: 600;
text-anchor: middle;
dominant-baseline: central;
pointer-events: none;
user-select: none;
}
.rubber-band {
fill: rgba(25, 113, 194, 0.08);