feat: frontend — IO markers + cable-type inspector
Slice 3 frontend.
+ IO tool (keyboard `I`):
- Single-click on canvas places a 30x30 diamond (rotated <rect>) at the
point, with the Power-cable_type colour fill (red-ish).
- Inline namer prompts for a label; empty → server defaults to "IO".
- Drop-point determines initial frame_id via the existing frameAt()
point-in-rect logic, same as devices.
Render:
- io_markers come from snap.io_markers in the snapshot loader. Each
renders as a <rect> with rotate(45) around its centre + a small text
label below the diamond. Selection halo on stroke-width.
- Drag is the same pointer-event flow as devices; on pointerup, PATCH
x,y + recompute frame_id from the new centre. Cross-frame moves
update frame_id with explicit null on the wire when leaving all frames.
- Frame-drag now also relocates contained IO markers (mirrors the
device-cascade pattern). Single PATCH per IO marker on release.
Cable-type inspector:
- Clicking a legend row now sets state.selection = {kind:"cable_type", id}
in addition to toggling activeTypeId. The inspector renders the cable
type's details (name + colour, both editable, with the
"shared across projects" banner from v3 §7), a used-by counter (0
until slice 7 ships cables), and a Delete button that surfaces the
RESTRICT in_use_by_cables count from the server.
- Debounced rename via the existing bindDebouncedRename helper.
Inspector frame view picks up an "IO" count alongside the device count.
Background click + Esc clear the selection (existing behaviour, now
covers cable_type too).
Hand-tested via the API equivalents: 3 IO markers created (free, in
frame, default-label), PATCH x,y + frame_id-to-null all work, cross-
project frame_id rejected with 400, DELETE 9999 returns 404. Snapshot
shape post-slice-3: {frames, devices, io_markers, cable_types} all
populated, ports/cables/bundles still [].
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user