Renders the slice-2 backend on the empty canvas from slice 1. Canvas: - Frames render as dashed-stroke rects with top-left label, slightly tinted fill. Devices render as solid-stroke rects with centred label in device.color. - Selection halo via .selected class (stroke-width bump). - Empty-state hint disappears once any geometry exists. Tools (left sidebar + keyboard): - F / + Frame — rubber-band rect on the canvas. <80×60 cancels. On release, inline foreignObject namer → POST /api/projects/:pid/frames. - D / + Device — single click places a 100×35 device centred at the click. Inline namer → POST devices. Drop-point determines initial frame_id via point-in-rect against all frames (smallest bbox wins). - Esc cancels active tool / inline namer / clears selection. Drag (pointer events + svg getScreenCTM): - Devices: drag updates x/y live via transform, persists via PATCH .../devices/:id on pointerup. Also recomputes frame_id from drop point and includes "frame_id": null|<id> if it changed. - Frames: dragging a frame moves its contained devices visually too; on pointerup, single PATCH for the frame + one PATCH per moved device. Children-batch is computed at pointerdown and only sent on release — no per-pointermove network traffic. Inspector: - Frame selection: name (debounced rename), x/y/w/h, device count, Delete button (confirm prompt — devices keep existing, frame_id → NULL via the schema's ON DELETE SET NULL). - Device selection: name (debounced rename), colour picker (change-event PATCH, no debounce), x/y/w/h, current frame, Delete. - Background click clears selection. devicePatch wire format uses tri-state frame_id: key absent = leave, key:null = clear, key:<int> = move. Frontend uses `null` explicitly when a device drops outside all frames.
844 lines
27 KiB
JavaScript
844 lines
27 KiB
JavaScript
// mCables frontend entry — vanilla ES module, no build step.
|
|
//
|
|
// Slice 2 adds: frame + device rendering, +Frm/+Dev tools, drag-to-position,
|
|
// inline naming, inspector for selection. State stays minimal: one
|
|
// snapshot from the server, then individual PATCHes on each mutation.
|
|
|
|
/**
|
|
* @typedef {{ id: number, name: string, drawing_name: string,
|
|
* description: string, created_at: string, updated_at: string }} Project
|
|
* @typedef {{ id: number, name: string, color: string,
|
|
* created_at: string, updated_at: string }} CableType
|
|
* @typedef {{ id: number, project_id: number, name: string,
|
|
* x: number, y: number, width: number, height: number }} Frame
|
|
* @typedef {{ id: number, project_id: number, frame_id: number|null,
|
|
* name: string, color: string,
|
|
* x: number, y: number, width: number, height: number }} Device
|
|
*/
|
|
|
|
const API = "/api";
|
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
|
|
const state = {
|
|
/** @type {Project[]} */ projects: [],
|
|
/** @type {CableType[]} */ cableTypes: [],
|
|
/** @type {Project | null} */ active: null,
|
|
/** @type {Frame[]} */ frames: [],
|
|
/** @type {Device[]} */ devices: [],
|
|
activeTypeId: /** @type {number|null} */ (null),
|
|
/** "frame" | "device" | null */
|
|
tool: /** @type {string|null} */ (null),
|
|
/** @type {{kind: "frame"|"device", id: number} | null} */ selection: null,
|
|
};
|
|
|
|
// ---------- API client ---------- //
|
|
|
|
async function api(method, path, body) {
|
|
const res = await fetch(API + path, {
|
|
method,
|
|
headers: body ? { "Content-Type": "application/json" } : undefined,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (res.status === 204) return null;
|
|
const text = await res.text();
|
|
const json = text ? JSON.parse(text) : null;
|
|
if (!res.ok) {
|
|
const err = new Error(json?.error || res.statusText);
|
|
err.status = res.status;
|
|
err.details = json?.details;
|
|
throw err;
|
|
}
|
|
return json;
|
|
}
|
|
|
|
const listProjects = () => api("GET", "/projects");
|
|
const createProject = (body) => api("POST", "/projects", body);
|
|
const deleteProject = (id, confirm) =>
|
|
api("DELETE", `/projects/${id}?confirm=${encodeURIComponent(confirm)}`);
|
|
const getSnapshot = (id) => api("GET", `/projects/${id}`);
|
|
|
|
const listCableTypes = () => api("GET", "/cable-types");
|
|
const createCableType = (body) => api("POST", "/cable-types", body);
|
|
const patchCableType = (id, body) => api("PATCH", `/cable-types/${id}`, body);
|
|
const deleteCableType = (id) => api("DELETE", `/cable-types/${id}`);
|
|
|
|
const createFrame = (pid, body) => api("POST", `/projects/${pid}/frames`, body);
|
|
const patchFrame = (pid, id, body) => api("PATCH", `/projects/${pid}/frames/${id}`, body);
|
|
const deleteFrame = (pid, id) => api("DELETE", `/projects/${pid}/frames/${id}`);
|
|
|
|
const createDevice = (pid, body) => api("POST", `/projects/${pid}/devices`, body);
|
|
const patchDevice = (pid, id, body) => api("PATCH", `/projects/${pid}/devices/${id}`, body);
|
|
const deleteDevice = (pid, id) => api("DELETE", `/projects/${pid}/devices/${id}`);
|
|
|
|
// ---------- DOM helpers ---------- //
|
|
|
|
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
|
|
|
|
function setHidden(el, hidden) {
|
|
if (hidden) el.setAttribute("hidden", "");
|
|
else el.removeAttribute("hidden");
|
|
}
|
|
|
|
function svgEl(name, attrs = {}) {
|
|
const el = document.createElementNS(SVG_NS, name);
|
|
for (const [k, v] of Object.entries(attrs)) {
|
|
if (v == null) continue;
|
|
el.setAttribute(k, String(v));
|
|
}
|
|
return el;
|
|
}
|
|
|
|
// ---------- URL state ---------- //
|
|
|
|
function activeProjectIdFromURL() {
|
|
const raw = new URLSearchParams(location.search).get("project");
|
|
const id = raw && Number.parseInt(raw, 10);
|
|
return Number.isFinite(id) && id > 0 ? id : null;
|
|
}
|
|
|
|
function setActiveInURL(id) {
|
|
const url = new URL(location.href);
|
|
if (id == null) url.searchParams.delete("project");
|
|
else url.searchParams.set("project", String(id));
|
|
history.replaceState(null, "", url.toString());
|
|
}
|
|
|
|
// ---------- geometry ---------- //
|
|
|
|
/** Returns the smallest frame whose bbox contains (x, y), or null. */
|
|
function frameAt(x, y) {
|
|
/** @type {Frame|null} */ let best = null;
|
|
let bestArea = Infinity;
|
|
for (const f of state.frames) {
|
|
if (x < f.x || x > f.x + f.width || y < f.y || y > f.y + f.height) continue;
|
|
const a = f.width * f.height;
|
|
if (a < bestArea) { best = f; bestArea = a; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/** Convert a pointer event to SVG-canvas coordinates. */
|
|
function svgPoint(evt) {
|
|
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
|
|
const pt = svg.createSVGPoint();
|
|
pt.x = evt.clientX;
|
|
pt.y = evt.clientY;
|
|
const ctm = svg.getScreenCTM();
|
|
if (!ctm) return { x: 0, y: 0 };
|
|
const local = pt.matrixTransform(ctm.inverse());
|
|
return { x: local.x, y: local.y };
|
|
}
|
|
|
|
// ---------- render ---------- //
|
|
|
|
function renderProjectPicker() {
|
|
const sel = /** @type {HTMLSelectElement} */ ($("#project-select"));
|
|
const current = state.active?.id ?? "";
|
|
sel.innerHTML = "";
|
|
sel.append(new Option("— pick a project —", ""));
|
|
for (const p of state.projects) {
|
|
const opt = new Option(p.name, String(p.id));
|
|
if (p.id === current) opt.selected = true;
|
|
sel.append(opt);
|
|
}
|
|
setHidden($("#btn-delete-project"), !state.active);
|
|
}
|
|
|
|
function renderLegend() {
|
|
const ul = $("#legend-list");
|
|
ul.innerHTML = "";
|
|
for (const t of state.cableTypes) {
|
|
const li = document.createElement("li");
|
|
li.className = "legend-row";
|
|
li.dataset.id = String(t.id);
|
|
if (state.activeTypeId === t.id) li.setAttribute("aria-current", "true");
|
|
li.innerHTML = `
|
|
<span class="legend-swatch" style="background:${t.color}"></span>
|
|
<span class="legend-name"></span>
|
|
<button type="button" class="legend-edit" aria-label="Edit cable type">edit</button>
|
|
`;
|
|
li.querySelector(".legend-name").textContent = t.name;
|
|
li.addEventListener("click", (e) => {
|
|
if (e.target instanceof HTMLElement && e.target.classList.contains("legend-edit")) {
|
|
openCableTypeModal(t);
|
|
e.stopPropagation();
|
|
return;
|
|
}
|
|
state.activeTypeId = state.activeTypeId === t.id ? null : t.id;
|
|
renderLegend();
|
|
});
|
|
ul.append(li);
|
|
}
|
|
}
|
|
|
|
function renderEmptyHint() {
|
|
const hint = $("#empty-hint");
|
|
if (!state.active) {
|
|
hint.textContent = state.projects.length
|
|
? "Pick a project from the dropdown to start drawing."
|
|
: "Create your first project to get started.";
|
|
setHidden(hint, false);
|
|
return;
|
|
}
|
|
if (state.frames.length === 0 && state.devices.length === 0) {
|
|
hint.textContent = `${state.active.name} — empty. Use + Frame / + Device to start (press F or D).`;
|
|
setHidden(hint, false);
|
|
} else {
|
|
setHidden(hint, true);
|
|
}
|
|
}
|
|
|
|
function renderCanvas() {
|
|
const gFrames = $("#canvas-frames");
|
|
const gDevices = $("#canvas-devices");
|
|
gFrames.innerHTML = "";
|
|
gDevices.innerHTML = "";
|
|
|
|
for (const f of state.frames) {
|
|
const g = svgEl("g", { "data-frame-id": f.id });
|
|
const rect = svgEl("rect", {
|
|
x: f.x, y: f.y, width: f.width, height: f.height,
|
|
class: "frame-rect svg-draggable",
|
|
rx: 6, ry: 6,
|
|
});
|
|
if (state.selection?.kind === "frame" && state.selection.id === f.id) {
|
|
rect.classList.add("selected");
|
|
}
|
|
const label = svgEl("text", {
|
|
x: f.x + 8, y: f.y + 18,
|
|
class: "frame-label",
|
|
});
|
|
label.textContent = f.name;
|
|
g.append(rect, label);
|
|
gFrames.append(g);
|
|
rect.addEventListener("pointerdown", (e) => startDrag(e, "frame", f.id));
|
|
}
|
|
|
|
for (const d of state.devices) {
|
|
const g = svgEl("g", { "data-device-id": d.id });
|
|
const rect = svgEl("rect", {
|
|
x: d.x, y: d.y, width: d.width, height: d.height,
|
|
class: "device-rect svg-draggable",
|
|
stroke: d.color,
|
|
rx: 3, ry: 3,
|
|
});
|
|
if (state.selection?.kind === "device" && state.selection.id === d.id) {
|
|
rect.classList.add("selected");
|
|
}
|
|
const label = svgEl("text", {
|
|
x: d.x + d.width / 2, y: d.y + d.height / 2,
|
|
class: "device-label",
|
|
});
|
|
label.textContent = d.name;
|
|
g.append(rect, label);
|
|
gDevices.append(g);
|
|
rect.addEventListener("pointerdown", (e) => startDrag(e, "device", d.id));
|
|
}
|
|
}
|
|
|
|
function renderInspector() {
|
|
const body = $("#inspector-body");
|
|
if (!state.selection) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
body.innerHTML = `
|
|
<p class="section-title">Frame</p>
|
|
<label class="field">
|
|
<span>Name</span>
|
|
<input class="inline-input" id="frm-name" value="" />
|
|
</label>
|
|
<dl>
|
|
<dt>x</dt><dd id="frm-x"></dd>
|
|
<dt>y</dt><dd id="frm-y"></dd>
|
|
<dt>w</dt><dd id="frm-w"></dd>
|
|
<dt>h</dt><dd id="frm-h"></dd>
|
|
<dt>devices</dt><dd id="frm-count"></dd>
|
|
</dl>
|
|
<div class="inspector-actions">
|
|
<button type="button" class="btn btn-danger btn-tiny" id="frm-delete">Delete frame</button>
|
|
</div>
|
|
`;
|
|
body.querySelector("#frm-name").value = f.name;
|
|
body.querySelector("#frm-x").textContent = f.x.toFixed(0);
|
|
body.querySelector("#frm-y").textContent = f.y.toFixed(0);
|
|
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);
|
|
|
|
bindDebouncedRename(body.querySelector("#frm-name"), async (name) => {
|
|
if (!state.active) return;
|
|
const updated = await patchFrame(state.active.id, f.id, { name });
|
|
Object.assign(f, updated);
|
|
renderCanvas();
|
|
});
|
|
|
|
body.querySelector("#frm-delete").addEventListener("click", () => {
|
|
if (!state.active) return;
|
|
if (!confirm(`Delete frame "${f.name}"? Its devices 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 d of state.devices) if (d.frame_id === f.id) d.frame_id = null;
|
|
state.selection = null;
|
|
render();
|
|
}).catch((e) => alert(`Delete failed: ${e.message}`));
|
|
});
|
|
}
|
|
|
|
function renderInspectorDevice(body, id) {
|
|
const d = state.devices.find((x) => x.id === id);
|
|
if (!d) { body.innerHTML = ""; return; }
|
|
const frame = d.frame_id ? state.frames.find((f) => f.id === d.frame_id) : null;
|
|
body.innerHTML = `
|
|
<p class="section-title">Device</p>
|
|
<label class="field">
|
|
<span>Name</span>
|
|
<input class="inline-input" id="dev-name" value="" />
|
|
</label>
|
|
<label class="field">
|
|
<span>Colour</span>
|
|
<input type="color" class="inline-input" id="dev-color" />
|
|
</label>
|
|
<dl>
|
|
<dt>x</dt><dd id="dev-x"></dd>
|
|
<dt>y</dt><dd id="dev-y"></dd>
|
|
<dt>w</dt><dd id="dev-w"></dd>
|
|
<dt>h</dt><dd id="dev-h"></dd>
|
|
<dt>frame</dt><dd id="dev-frame"></dd>
|
|
</dl>
|
|
<div class="inspector-actions">
|
|
<button type="button" class="btn btn-danger btn-tiny" id="dev-delete">Delete device</button>
|
|
</div>
|
|
`;
|
|
body.querySelector("#dev-name").value = d.name;
|
|
body.querySelector("#dev-color").value = d.color;
|
|
body.querySelector("#dev-x").textContent = d.x.toFixed(0);
|
|
body.querySelector("#dev-y").textContent = d.y.toFixed(0);
|
|
body.querySelector("#dev-w").textContent = d.width.toFixed(0);
|
|
body.querySelector("#dev-h").textContent = d.height.toFixed(0);
|
|
body.querySelector("#dev-frame").textContent = frame ? frame.name : "—";
|
|
|
|
bindDebouncedRename(body.querySelector("#dev-name"), async (name) => {
|
|
if (!state.active) return;
|
|
const updated = await patchDevice(state.active.id, d.id, { name });
|
|
Object.assign(d, updated);
|
|
renderCanvas();
|
|
});
|
|
|
|
// Colour changes need no debounce — the native colour picker only fires
|
|
// `change` on commit.
|
|
body.querySelector("#dev-color").addEventListener("change", async (e) => {
|
|
if (!state.active) return;
|
|
const color = /** @type {HTMLInputElement} */ (e.target).value;
|
|
try {
|
|
const updated = await patchDevice(state.active.id, d.id, { color });
|
|
Object.assign(d, updated);
|
|
renderCanvas();
|
|
} catch (err) {
|
|
alert(`Colour update failed: ${err.message}`);
|
|
}
|
|
});
|
|
|
|
body.querySelector("#dev-delete").addEventListener("click", () => {
|
|
if (!state.active) return;
|
|
if (!confirm(`Delete device "${d.name}"?`)) return;
|
|
deleteDevice(state.active.id, d.id).then(() => {
|
|
state.devices = state.devices.filter((x) => x.id !== d.id);
|
|
state.selection = null;
|
|
render();
|
|
}).catch((e) => alert(`Delete failed: ${e.message}`));
|
|
});
|
|
}
|
|
|
|
function bindDebouncedRename(input, persist) {
|
|
let timer = null;
|
|
input.addEventListener("input", () => {
|
|
if (timer) clearTimeout(timer);
|
|
timer = setTimeout(() => {
|
|
const v = input.value.trim();
|
|
if (v) persist(v).catch((e) => alert(`Save failed: ${e.message}`));
|
|
}, 400);
|
|
});
|
|
input.addEventListener("blur", () => {
|
|
if (timer) { clearTimeout(timer); timer = null; }
|
|
const v = input.value.trim();
|
|
if (v && v !== input.dataset.last) {
|
|
persist(v).catch((e) => alert(`Save failed: ${e.message}`));
|
|
input.dataset.last = v;
|
|
}
|
|
});
|
|
}
|
|
|
|
function render() {
|
|
renderProjectPicker();
|
|
renderLegend();
|
|
renderCanvas();
|
|
renderEmptyHint();
|
|
renderInspector();
|
|
}
|
|
|
|
// ---------- active project ---------- //
|
|
|
|
async function activateProject(id) {
|
|
if (id == null) {
|
|
state.active = null;
|
|
state.frames = [];
|
|
state.devices = [];
|
|
state.selection = null;
|
|
setActiveInURL(null);
|
|
render();
|
|
return;
|
|
}
|
|
try {
|
|
const snap = await getSnapshot(id);
|
|
state.active = snap.project;
|
|
state.frames = snap.frames || [];
|
|
state.devices = snap.devices || [];
|
|
state.cableTypes = snap.cable_types || [];
|
|
state.selection = null;
|
|
setActiveInURL(id);
|
|
render();
|
|
} catch (err) {
|
|
if (err.status === 404) {
|
|
state.active = null;
|
|
state.frames = [];
|
|
state.devices = [];
|
|
setActiveInURL(null);
|
|
render();
|
|
} else {
|
|
alert(`Failed to load project: ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- tools ---------- //
|
|
|
|
function armTool(tool) {
|
|
if (state.tool === tool) tool = null; // toggle off
|
|
state.tool = tool;
|
|
const wrap = $(".canvas-wrap");
|
|
wrap.classList.toggle("tool-frame", tool === "frame");
|
|
wrap.classList.toggle("tool-device", tool === "device");
|
|
for (const btn of document.querySelectorAll("[data-tool]")) {
|
|
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
|
|
}
|
|
}
|
|
|
|
function bindTools() {
|
|
for (const btn of document.querySelectorAll("[data-tool]")) {
|
|
btn.addEventListener("click", () => armTool(btn.getAttribute("data-tool")));
|
|
}
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
// Avoid stealing keys while user is typing into an input.
|
|
const tag = (e.target instanceof HTMLElement) ? e.target.tagName : "";
|
|
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
|
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");
|
|
});
|
|
|
|
// Canvas-level pointerdown handles tool activation + selection clearing.
|
|
$("#canvas").addEventListener("pointerdown", onCanvasPointerDown);
|
|
}
|
|
|
|
let rubberBand = /** @type {SVGRectElement|null} */ (null);
|
|
let rubberStart = /** @type {{x:number,y:number}|null} */ (null);
|
|
|
|
function onCanvasPointerDown(e) {
|
|
if (!state.active) return;
|
|
// Ignore clicks that started on a device/frame — their own handlers
|
|
// captured the pointer already.
|
|
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id]")) return;
|
|
|
|
const p = svgPoint(e);
|
|
|
|
if (state.tool === "frame") {
|
|
startFrameRubberBand(e, p);
|
|
return;
|
|
}
|
|
if (state.tool === "device") {
|
|
placeDeviceAt(p);
|
|
return;
|
|
}
|
|
// Plain canvas click = clear selection.
|
|
if (state.selection) { state.selection = null; render(); }
|
|
}
|
|
|
|
function startFrameRubberBand(e, p0) {
|
|
if (!state.active) return;
|
|
rubberStart = p0;
|
|
rubberBand = svgEl("rect", {
|
|
x: p0.x, y: p0.y, width: 0, height: 0,
|
|
class: "rubber-band", rx: 6, ry: 6,
|
|
});
|
|
$("#canvas").append(rubberBand);
|
|
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
|
|
svg.setPointerCapture(e.pointerId);
|
|
|
|
const onMove = (ev) => {
|
|
if (!rubberBand || !rubberStart) return;
|
|
const p = svgPoint(ev);
|
|
const x = Math.min(rubberStart.x, p.x);
|
|
const y = Math.min(rubberStart.y, p.y);
|
|
rubberBand.setAttribute("x", String(x));
|
|
rubberBand.setAttribute("y", String(y));
|
|
rubberBand.setAttribute("width", String(Math.abs(p.x - rubberStart.x)));
|
|
rubberBand.setAttribute("height", String(Math.abs(p.y - rubberStart.y)));
|
|
};
|
|
const onUp = async (ev) => {
|
|
svg.removeEventListener("pointermove", onMove);
|
|
svg.removeEventListener("pointerup", onUp);
|
|
svg.releasePointerCapture(e.pointerId);
|
|
const rect = rubberBand;
|
|
const start = rubberStart;
|
|
rubberBand = null;
|
|
rubberStart = null;
|
|
if (!rect || !start) return;
|
|
const w = Number(rect.getAttribute("width"));
|
|
const h = Number(rect.getAttribute("height"));
|
|
const x = Number(rect.getAttribute("x"));
|
|
const y = Number(rect.getAttribute("y"));
|
|
rect.remove();
|
|
if (w < 80 || h < 60) { armTool(null); return; }
|
|
armTool(null);
|
|
const name = await promptInline("Frame name", x + w / 2, y + 16);
|
|
if (!name || !state.active) return;
|
|
try {
|
|
const f = await createFrame(state.active.id, { name, x, y, width: w, height: h });
|
|
state.frames.push(f);
|
|
state.selection = { kind: "frame", id: f.id };
|
|
render();
|
|
} catch (err) {
|
|
alert(`Create frame failed: ${err.message}`);
|
|
}
|
|
};
|
|
svg.addEventListener("pointermove", onMove);
|
|
svg.addEventListener("pointerup", onUp);
|
|
}
|
|
|
|
async function placeDeviceAt(p) {
|
|
if (!state.active) return;
|
|
armTool(null);
|
|
const W = 100, H = 35;
|
|
const x = p.x - W / 2;
|
|
const y = p.y - H / 2;
|
|
const name = await promptInline("Device name", p.x, p.y);
|
|
if (!name || !state.active) return;
|
|
const frame = frameAt(p.x, p.y);
|
|
try {
|
|
const d = await createDevice(state.active.id, {
|
|
name, x, y, width: W, height: H,
|
|
frame_id: frame ? frame.id : undefined,
|
|
});
|
|
state.devices.push(d);
|
|
state.selection = { kind: "device", id: d.id };
|
|
render();
|
|
} catch (err) {
|
|
alert(`Create device failed: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// ---------- inline namer (foreignObject overlay) ---------- //
|
|
|
|
let activeNamer = /** @type {SVGForeignObjectElement|null} */ (null);
|
|
|
|
function cancelInlineNamer() {
|
|
if (activeNamer) { activeNamer.remove(); activeNamer = null; }
|
|
}
|
|
|
|
function promptInline(placeholder, cx, cy) {
|
|
cancelInlineNamer();
|
|
return new Promise((resolve) => {
|
|
const fo = document.createElementNS(SVG_NS, "foreignObject");
|
|
fo.setAttribute("x", String(cx - 110));
|
|
fo.setAttribute("y", String(cy - 14));
|
|
fo.setAttribute("width", "220");
|
|
fo.setAttribute("height", "28");
|
|
fo.innerHTML = `
|
|
<div class="inline-namer" xmlns="http://www.w3.org/1999/xhtml">
|
|
<input type="text" placeholder="${placeholder}" />
|
|
</div>
|
|
`;
|
|
$("#canvas").append(fo);
|
|
activeNamer = fo;
|
|
const input = fo.querySelector("input");
|
|
input.focus();
|
|
const done = (val) => {
|
|
if (activeNamer === fo) { fo.remove(); activeNamer = null; }
|
|
resolve(val);
|
|
};
|
|
input.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") done(input.value.trim());
|
|
else if (e.key === "Escape") done(null);
|
|
});
|
|
input.addEventListener("blur", () => done(input.value.trim() || null));
|
|
});
|
|
}
|
|
|
|
// ---------- drag ---------- //
|
|
|
|
function startDrag(e, kind, id) {
|
|
if (!state.active) return;
|
|
if (state.tool) return; // a tool is armed; don't hijack
|
|
e.stopPropagation();
|
|
state.selection = { 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);
|
|
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.
|
|
let trackedDevices = /** @type {{d: Device, 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 });
|
|
}
|
|
}
|
|
}
|
|
|
|
e.currentTarget.classList.add("dragging");
|
|
svg.setPointerCapture(e.pointerId);
|
|
|
|
let dragged = false;
|
|
|
|
const onMove = (ev) => {
|
|
const p = svgPoint(ev);
|
|
const dx = p.x - start.x;
|
|
const dy = p.y - start.y;
|
|
if (!dragged && (Math.abs(dx) + Math.abs(dy) > 1)) dragged = true;
|
|
obj.x = startX + dx;
|
|
obj.y = startY + dy;
|
|
if (kind === "frame") {
|
|
for (const t of trackedDevices) { t.d.x = t.sx + dx; t.d.y = t.sy + dy; }
|
|
}
|
|
renderCanvas();
|
|
};
|
|
const onUp = async (ev) => {
|
|
svg.removeEventListener("pointermove", onMove);
|
|
svg.removeEventListener("pointerup", onUp);
|
|
svg.releasePointerCapture(e.pointerId);
|
|
e.currentTarget.classList.remove("dragging");
|
|
|
|
if (!dragged) { render(); return; } // click only — re-render to apply selection halo
|
|
if (!state.active) return;
|
|
|
|
try {
|
|
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) =>
|
|
patchDevice(state.active.id, t.d.id, { x: t.d.x, y: t.d.y })),
|
|
);
|
|
} else {
|
|
const d = /** @type {Device} */ (obj);
|
|
// Recompute frame_id from drop point (centre of device).
|
|
const cx = d.x + d.width / 2;
|
|
const cy = d.y + d.height / 2;
|
|
const targetFrame = frameAt(cx, cy);
|
|
const newFrameID = targetFrame ? targetFrame.id : null;
|
|
const patchBody = { x: d.x, y: d.y };
|
|
if ((d.frame_id ?? null) !== newFrameID) {
|
|
patchBody.frame_id = newFrameID; // explicit null = clear
|
|
d.frame_id = newFrameID;
|
|
}
|
|
await patchDevice(state.active.id, d.id, patchBody);
|
|
}
|
|
} catch (err) {
|
|
alert(`Save failed: ${err.message}`);
|
|
}
|
|
render();
|
|
};
|
|
svg.addEventListener("pointermove", onMove);
|
|
svg.addEventListener("pointerup", onUp);
|
|
}
|
|
|
|
// ---------- modals (project / cable type) ---------- //
|
|
|
|
function bindCloseButtons(dialog) {
|
|
dialog.querySelectorAll("[data-close]").forEach((btn) =>
|
|
btn.addEventListener("click", () => dialog.close()),
|
|
);
|
|
}
|
|
|
|
function showError(el, msg) {
|
|
if (!msg) { setHidden(el, true); el.textContent = ""; return; }
|
|
el.textContent = msg;
|
|
setHidden(el, false);
|
|
}
|
|
|
|
function openNewProjectModal() {
|
|
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-new-project"));
|
|
const form = /** @type {HTMLFormElement} */ ($("#form-new-project"));
|
|
const err = $("#np-error");
|
|
form.reset();
|
|
showError(err, "");
|
|
dlg.showModal();
|
|
form.elements.namedItem("name").focus();
|
|
|
|
form.onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(form);
|
|
const body = {
|
|
name: String(fd.get("name") || "").trim(),
|
|
drawing_name: String(fd.get("drawing_name") || "").trim(),
|
|
description: String(fd.get("description") || ""),
|
|
};
|
|
if (!body.drawing_name) delete body.drawing_name;
|
|
try {
|
|
const p = await createProject(body);
|
|
state.projects = await listProjects();
|
|
dlg.close();
|
|
await activateProject(p.id);
|
|
} catch (e) {
|
|
showError(err, e.message || "Failed to create project");
|
|
}
|
|
};
|
|
}
|
|
|
|
function openCableTypeModal(existing) {
|
|
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-cable-type"));
|
|
const form = /** @type {HTMLFormElement} */ ($("#form-cable-type"));
|
|
const err = $("#ct-error");
|
|
const title = $("#ct-title");
|
|
form.reset();
|
|
showError(err, "");
|
|
title.textContent = existing ? `Edit "${existing.name}"` : "New cable type";
|
|
|
|
if (existing) {
|
|
form.elements.namedItem("name").value = existing.name;
|
|
form.elements.namedItem("color").value = existing.color;
|
|
} else {
|
|
form.elements.namedItem("color").value = "#1971c2";
|
|
}
|
|
|
|
const actions = form.querySelector(".actions");
|
|
actions.querySelector(".btn-delete-type")?.remove();
|
|
if (existing) {
|
|
const del = document.createElement("button");
|
|
del.type = "button";
|
|
del.className = "btn btn-danger btn-delete-type";
|
|
del.style.marginRight = "auto";
|
|
del.textContent = "Delete";
|
|
del.addEventListener("click", async () => {
|
|
try {
|
|
await deleteCableType(existing.id);
|
|
state.cableTypes = await listCableTypes();
|
|
if (state.activeTypeId === existing.id) state.activeTypeId = null;
|
|
dlg.close();
|
|
render();
|
|
} catch (e) {
|
|
const n = e.details?.in_use_by_cables;
|
|
showError(err, n ? `In use by ${n} cable${n === 1 ? "" : "s"}` : (e.message || "Delete failed"));
|
|
}
|
|
});
|
|
actions.prepend(del);
|
|
}
|
|
|
|
dlg.showModal();
|
|
form.elements.namedItem("name").focus();
|
|
|
|
form.onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(form);
|
|
const body = {
|
|
name: String(fd.get("name") || "").trim(),
|
|
color: String(fd.get("color") || "").trim(),
|
|
};
|
|
try {
|
|
if (existing) await patchCableType(existing.id, body);
|
|
else await createCableType(body);
|
|
state.cableTypes = await listCableTypes();
|
|
dlg.close();
|
|
render();
|
|
} catch (e) {
|
|
showError(err, e.message || "Save failed");
|
|
}
|
|
};
|
|
}
|
|
|
|
function openDeleteProjectModal() {
|
|
if (!state.active) return;
|
|
const dlg = /** @type {HTMLDialogElement} */ ($("#modal-delete-project"));
|
|
const form = /** @type {HTMLFormElement} */ ($("#form-delete-project"));
|
|
const err = $("#dp-error");
|
|
const input = /** @type {HTMLInputElement} */ ($("#dp-confirm-input"));
|
|
form.reset();
|
|
showError(err, "");
|
|
input.placeholder = state.active.name;
|
|
dlg.showModal();
|
|
input.focus();
|
|
|
|
form.onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
const confirm = String(new FormData(form).get("confirm") || "");
|
|
try {
|
|
await deleteProject(state.active.id, confirm);
|
|
state.projects = await listProjects();
|
|
dlg.close();
|
|
await activateProject(null);
|
|
} catch (e) {
|
|
showError(err, e.message || "Delete failed");
|
|
}
|
|
};
|
|
}
|
|
|
|
// ---------- boot ---------- //
|
|
|
|
async function boot() {
|
|
bindCloseButtons($("#modal-new-project"));
|
|
bindCloseButtons($("#modal-cable-type"));
|
|
bindCloseButtons($("#modal-delete-project"));
|
|
|
|
$("#btn-new-project").addEventListener("click", openNewProjectModal);
|
|
$("#btn-add-type").addEventListener("click", () => openCableTypeModal(null));
|
|
$("#btn-delete-project").addEventListener("click", openDeleteProjectModal);
|
|
|
|
$("#project-select").addEventListener("change", (e) => {
|
|
const v = /** @type {HTMLSelectElement} */ (e.target).value;
|
|
activateProject(v ? Number(v) : null);
|
|
});
|
|
|
|
bindTools();
|
|
|
|
try {
|
|
[state.projects, state.cableTypes] = await Promise.all([
|
|
listProjects(),
|
|
listCableTypes(),
|
|
]);
|
|
} catch (e) {
|
|
alert(`Failed to load: ${e.message}`);
|
|
return;
|
|
}
|
|
|
|
const wanted = activeProjectIdFromURL();
|
|
if (wanted && state.projects.some((p) => p.id === wanted)) {
|
|
await activateProject(wanted);
|
|
} else {
|
|
render();
|
|
}
|
|
}
|
|
|
|
boot();
|