feat: frontend — frames + devices on SVG, tools, drag, inspector
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.
This commit is contained in:
@@ -35,8 +35,8 @@
|
||||
<section class="tools">
|
||||
<h2 class="sidebar-heading">Tools</h2>
|
||||
<ul class="tool-list">
|
||||
<li><button type="button" class="btn btn-tiny" disabled title="Slice 2">+ Frame</button></li>
|
||||
<li><button type="button" class="btn btn-tiny" disabled title="Slice 2">+ Device</button></li>
|
||||
<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>
|
||||
</ul>
|
||||
@@ -58,7 +58,9 @@
|
||||
|
||||
<aside class="inspector" aria-label="Inspector">
|
||||
<h2 class="sidebar-heading">Inspector</h2>
|
||||
<p class="muted">Nothing selected.</p>
|
||||
<div id="inspector-body">
|
||||
<p class="muted">Nothing selected.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
// mCables frontend entry — vanilla ES module, no build step.
|
||||
//
|
||||
// Slice 1 covers: list/create/delete projects, list/create/edit/delete
|
||||
// global cable types, and reflect the active project in ?project=<id>.
|
||||
// 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,
|
||||
/** active cable-type id (used for drawing in later slices) */
|
||||
activeTypeId: 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 ---------- //
|
||||
@@ -42,7 +53,6 @@ async function api(method, path, body) {
|
||||
|
||||
const listProjects = () => api("GET", "/projects");
|
||||
const createProject = (body) => api("POST", "/projects", body);
|
||||
const patchProject = (id, body) => api("PATCH", `/projects/${id}`, body);
|
||||
const deleteProject = (id, confirm) =>
|
||||
api("DELETE", `/projects/${id}?confirm=${encodeURIComponent(confirm)}`);
|
||||
const getSnapshot = (id) => api("GET", `/projects/${id}`);
|
||||
@@ -52,6 +62,14 @@ 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));
|
||||
@@ -61,6 +79,15 @@ function setHidden(el, 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() {
|
||||
@@ -76,14 +103,39 @@ function setActiveInURL(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 = "";
|
||||
const blank = new Option("— pick a project —", "");
|
||||
sel.append(blank);
|
||||
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;
|
||||
@@ -126,16 +178,214 @@ function renderEmptyHint() {
|
||||
? "Pick a project from the dropdown to start drawing."
|
||||
: "Create your first project to get started.";
|
||||
setHidden(hint, false);
|
||||
} else {
|
||||
hint.textContent = `${state.active.name} — slice 1: empty canvas. Frames + devices arrive in slice 2.`;
|
||||
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 ---------- //
|
||||
@@ -143,6 +393,9 @@ function render() {
|
||||
async function activateProject(id) {
|
||||
if (id == null) {
|
||||
state.active = null;
|
||||
state.frames = [];
|
||||
state.devices = [];
|
||||
state.selection = null;
|
||||
setActiveInURL(null);
|
||||
render();
|
||||
return;
|
||||
@@ -150,15 +403,17 @@ async function activateProject(id) {
|
||||
try {
|
||||
const snap = await getSnapshot(id);
|
||||
state.active = snap.project;
|
||||
// The snapshot also returns the global cable types — refresh from
|
||||
// the source of truth so a stale state.cableTypes can never linger.
|
||||
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) {
|
||||
// The id in the URL points to a deleted project — clear it.
|
||||
state.active = null;
|
||||
state.frames = [];
|
||||
state.devices = [];
|
||||
setActiveInURL(null);
|
||||
render();
|
||||
} else {
|
||||
@@ -167,7 +422,259 @@ async function activateProject(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- modals ---------- //
|
||||
// ---------- 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) =>
|
||||
@@ -226,7 +733,6 @@ function openCableTypeModal(existing) {
|
||||
form.elements.namedItem("color").value = "#1971c2";
|
||||
}
|
||||
|
||||
// Slot in a Delete button when editing an existing type.
|
||||
const actions = form.querySelector(".actions");
|
||||
actions.querySelector(".btn-delete-type")?.remove();
|
||||
if (existing) {
|
||||
@@ -314,6 +820,8 @@ async function boot() {
|
||||
activateProject(v ? Number(v) : null);
|
||||
});
|
||||
|
||||
bindTools();
|
||||
|
||||
try {
|
||||
[state.projects, state.cableTypes] = await Promise.all([
|
||||
listProjects(),
|
||||
|
||||
@@ -165,6 +165,122 @@ body {
|
||||
|
||||
.muted { color: var(--text-muted); }
|
||||
|
||||
/* ---------- canvas elements ---------- */
|
||||
|
||||
.frame-rect {
|
||||
fill: rgba(25, 113, 194, 0.04);
|
||||
stroke: var(--accent);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 6 4;
|
||||
}
|
||||
.frame-rect.selected,
|
||||
.frame-rect:hover { stroke-width: 2.5; }
|
||||
|
||||
.frame-label {
|
||||
fill: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.device-rect {
|
||||
fill: #fff;
|
||||
stroke: var(--text);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.device-rect.selected { stroke-width: 3; }
|
||||
.device-rect:hover { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.15)); }
|
||||
|
||||
.device-label {
|
||||
fill: var(--text);
|
||||
font-size: 12px;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.svg-draggable { cursor: grab; }
|
||||
.svg-draggable.dragging { cursor: grabbing; }
|
||||
|
||||
/* tool cursor on the empty canvas while a tool is armed */
|
||||
.canvas-wrap.tool-frame #canvas,
|
||||
.canvas-wrap.tool-device #canvas { cursor: crosshair; }
|
||||
|
||||
.rubber-band {
|
||||
fill: rgba(25, 113, 194, 0.08);
|
||||
stroke: var(--accent);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* tool buttons toggle armed-state */
|
||||
.btn[data-tool].armed {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- inspector ---------- */
|
||||
|
||||
.inspector dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr;
|
||||
gap: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.inspector dt { color: var(--text-muted); }
|
||||
.inspector dd { margin: 0; }
|
||||
|
||||
.inspector .inline-input {
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
.inspector .inline-input:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.inspector .section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin: 12px 0 6px 0;
|
||||
}
|
||||
.inspector .inspector-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* foreignObject used to inline-name a freshly-placed frame/device */
|
||||
.inline-namer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.inline-namer input {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
width: calc(100% - 8px);
|
||||
max-width: 200px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
|
||||
.btn {
|
||||
|
||||
Reference in New Issue
Block a user