feat(v5 slice 3): clamp render + Place tool + inspector
Frontend hooks for the v5 routing primitive. - state gains clamps + cableClamps arrays, hydrated from the snapshot (`clamps`, `cable_clamps`). Reset on null-project + project-404 paths. - API helpers: createClamp / patchClamp / deleteClamp + attach / detach / reorder cable_clamps. - +Clamp tool button + "C" keyboard shortcut. armTool flips the tool-clamp class on .canvas-wrap (crosshair cursor). - onCanvasPointerDown routes tool === "clamp" to placeClampAt, which POSTs a clamp at the click position. If the click target is on a cable, the new clamp is also attached to that cable in one go. - renderCanvas paints clamps as 12×12 rounded squares (per design v5 §11.9 q1) in a new #canvas-clamps <g>. Drag uses the existing startDrag pipeline (kind="clamp"), which now also moves clamps when their containing frame is dragged. - renderInspectorClamp shows label + position + cables-through list + Delete (with cascade confirm when shared). Slice 4 wires the clamp into a cable's polyline (mid-segment drag, visual routing); for now placing a clamp on top of a cable just attaches it.
This commit is contained in:
@@ -44,6 +44,7 @@
|
||||
<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" id="tool-io" class="btn btn-tiny" data-tool="io">+ IO</button></li>
|
||||
<li><button type="button" id="tool-clamp" class="btn btn-tiny" data-tool="clamp" title="Click canvas to drop a clamp. Cables can then route through it.">+ Clamp</button></li>
|
||||
<li><button type="button" id="tool-req" class="btn btn-tiny" data-tool="req">Drag req A→B</button></li>
|
||||
<li><button type="button" id="tool-cable" class="btn btn-tiny" data-tool="cable" title="Click a port to start, then click another port / device / IO marker">Draw cable</button></li>
|
||||
</ul>
|
||||
@@ -56,6 +57,7 @@
|
||||
<g id="canvas-devices"></g>
|
||||
<g id="canvas-ports"></g>
|
||||
<g id="canvas-cables"></g>
|
||||
<g id="canvas-clamps"></g>
|
||||
<g id="canvas-io"></g>
|
||||
</svg>
|
||||
<p id="empty-hint" class="empty-hint">
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
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 CLAMP_SIZE = 12; // small rounded square for routing clamps (v5 §11)
|
||||
|
||||
const state = {
|
||||
/** @type {Project[]} */ projects: [],
|
||||
@@ -55,8 +56,11 @@ const state = {
|
||||
/** @type {Cable[]} */ cables: [],
|
||||
/** @type {Bundle[]} */ bundles: [],
|
||||
/** @type {SetupTemplate[]} */ setupTemplates: [],
|
||||
/** v5 — routing anchors. */
|
||||
/** @type {Clamp[]} */ clamps: [],
|
||||
/** @type {CableClamp[]} */ cableClamps: [],
|
||||
activeTypeId: /** @type {number|null} */ (null),
|
||||
/** "frame" | "device" | "io" | "req" | "cable" | null */
|
||||
/** "frame" | "device" | "io" | "req" | "cable" | "clamp" | null */
|
||||
tool: /** @type {string|null} */ (null),
|
||||
/** Canvas viewport — drives the SVG viewBox. */
|
||||
view: { x: 0, y: 0, zoom: 1 },
|
||||
@@ -64,7 +68,7 @@ const state = {
|
||||
spaceHeld: false,
|
||||
/** Slice-7: when the user clicked a source port, this is its id. */
|
||||
cableDrawFromPortID: /** @type {number|null} */ (null),
|
||||
/** @type {({kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port", id: number} | {kind: "port_new", device_id: number}) | null} */ selection: null,
|
||||
/** @type {({kind: "frame"|"device"|"io"|"cable_type"|"requirement"|"cable"|"bundle"|"port"|"clamp", id: number} | {kind: "port_new", device_id: number}) | null} */ selection: null,
|
||||
};
|
||||
|
||||
// ---------- API client ---------- //
|
||||
@@ -135,6 +139,15 @@ const listSetupTemplates = () => api("GET", `/setup-templates`);
|
||||
const applyTemplate = (pid, body) => api("POST", `/projects/${pid}/apply-template`, body);
|
||||
const syncExport = (pid) => api("POST", `/projects/${pid}/sync/export`, {});
|
||||
|
||||
// v5 — clamps + cable_clamps.
|
||||
const listClamps = (pid) => api("GET", `/projects/${pid}/clamps`);
|
||||
const createClamp = (pid, body) => api("POST", `/projects/${pid}/clamps`, body);
|
||||
const patchClamp = (pid, id, body) => api("PATCH", `/projects/${pid}/clamps/${id}`, body);
|
||||
const deleteClamp = (pid, id) => api("DELETE", `/projects/${pid}/clamps/${id}`);
|
||||
const attachClampToCable = (pid, cid, body) => api("POST", `/projects/${pid}/cables/${cid}/clamps`, body);
|
||||
const detachClampFromCable = (pid, cid, cmid) => api("DELETE", `/projects/${pid}/cables/${cid}/clamps/${cmid}`);
|
||||
const reorderCableClamps = (pid, cid, body) => api("PUT", `/projects/${pid}/cables/${cid}/clamps`, body);
|
||||
|
||||
// ---------- DOM helpers ---------- //
|
||||
|
||||
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
|
||||
@@ -393,10 +406,12 @@ function renderCanvas() {
|
||||
const gFrames = $("#canvas-frames");
|
||||
const gDevices = $("#canvas-devices");
|
||||
const gCables = $("#canvas-cables");
|
||||
const gClamps = $("#canvas-clamps");
|
||||
const gIO = $("#canvas-io");
|
||||
gFrames.innerHTML = "";
|
||||
gDevices.innerHTML = "";
|
||||
gCables.innerHTML = "";
|
||||
gClamps.innerHTML = "";
|
||||
gIO.innerHTML = "";
|
||||
|
||||
for (const f of state.frames) {
|
||||
@@ -539,6 +554,30 @@ function renderCanvas() {
|
||||
});
|
||||
}
|
||||
|
||||
// Clamps — small grey rounded squares (per design v5 §11.9 q1).
|
||||
// Slice 4 wires them into cable polylines; for slice 3 they just
|
||||
// render + drag + select.
|
||||
for (const cl of state.clamps) {
|
||||
const g = svgEl("g", { "data-clamp-id": cl.id });
|
||||
const sz = CLAMP_SIZE;
|
||||
const rect = svgEl("rect", {
|
||||
x: cl.x - sz / 2, y: cl.y - sz / 2, width: sz, height: sz,
|
||||
rx: 2, ry: 2,
|
||||
class: "clamp" + (state.selection?.kind === "clamp" && state.selection.id === cl.id ? " selected" : "") + " svg-draggable",
|
||||
});
|
||||
g.append(rect);
|
||||
if (cl.label) {
|
||||
const label = svgEl("text", {
|
||||
x: cl.x + sz / 2 + 4, y: cl.y + 3,
|
||||
class: "clamp-label",
|
||||
});
|
||||
label.textContent = cl.label;
|
||||
g.append(label);
|
||||
}
|
||||
gClamps.append(g);
|
||||
rect.addEventListener("pointerdown", (e) => startDrag(e, "clamp", cl.id));
|
||||
}
|
||||
|
||||
// Cables — straight lines between resolved endpoint anchors.
|
||||
// Auto-cables render with dashed stroke so m sees which the solver
|
||||
// placed; manual cables are solid.
|
||||
@@ -629,6 +668,7 @@ function renderInspector() {
|
||||
case "cable": return renderInspectorCable(body, state.selection.id);
|
||||
case "port": return renderInspectorPort(body, state.selection.id);
|
||||
case "port_new": return renderInspectorPortNew(body, state.selection.device_id);
|
||||
case "clamp": return renderInspectorClamp(body, state.selection.id);
|
||||
default: body.innerHTML = `<p class="muted">Nothing selected.</p>`;
|
||||
}
|
||||
}
|
||||
@@ -1217,6 +1257,112 @@ function renderInspectorIO(body, id) {
|
||||
});
|
||||
}
|
||||
|
||||
// Clamp inspector — label + position + cables-through list + delete.
|
||||
// Slice 4 wires the cables-through list to actual data; for slice 3 it
|
||||
// reads whatever's already on state.cableClamps (initially empty for a
|
||||
// freshly-placed clamp).
|
||||
function renderInspectorClamp(body, id) {
|
||||
const cl = state.clamps.find((x) => x.id === id);
|
||||
if (!cl) { body.innerHTML = ""; return; }
|
||||
const frame = cl.frame_id ? state.frames.find((f) => f.id === cl.frame_id) : null;
|
||||
const cableTypeColor = new Map(state.cableTypes.map((t) => [t.id, t.color]));
|
||||
const deviceByID = new Map(state.devices.map((d) => [d.id, d]));
|
||||
const portByID = new Map(state.ports.map((p) => [p.id, p]));
|
||||
const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m]));
|
||||
const cablesThrough = state.cableClamps
|
||||
.filter((cc) => cc.clamp_id === id)
|
||||
.map((cc) => state.cables.find((c) => c.id === cc.cable_id))
|
||||
.filter(Boolean);
|
||||
function endpointLabel(c, end) {
|
||||
const portID = end === "from" ? c.from_port_id : c.to_port_id;
|
||||
const devID = end === "from" ? c.from_device_id : c.to_device_id;
|
||||
const ioID = end === "from" ? c.from_io_id : c.to_io_id;
|
||||
if (portID != null) {
|
||||
const p = portByID.get(portID);
|
||||
const d = p && deviceByID.get(p.device_id);
|
||||
return `${d?.name ?? "?"} · ${p?.label ?? "port"}`;
|
||||
}
|
||||
if (devID != null) return deviceByID.get(devID)?.name ?? "(missing device)";
|
||||
if (ioID != null) return ioByID.get(ioID)?.label ?? "(missing IO)";
|
||||
return "?";
|
||||
}
|
||||
const cablesHtml = cablesThrough.length
|
||||
? cablesThrough.map((c) => `
|
||||
<div class="port-row" data-cable-id="${c.id}">
|
||||
<span class="swatch" style="background:${cableTypeColor.get(c.type_id) || "#888"}"></span>
|
||||
<span class="label">${escapeHtml(endpointLabel(c, "from"))} ↔ ${escapeHtml(endpointLabel(c, "to"))}</span>
|
||||
<span class="conn">
|
||||
<button type="button" class="btn-link clamp-detach" data-cable-id="${c.id}" title="Remove this clamp from the cable">×</button>
|
||||
</span>
|
||||
</div>`).join("")
|
||||
: `<p class="muted" style="font-size:12px">Not on any cable yet.</p>`;
|
||||
|
||||
body.innerHTML = `
|
||||
<p class="section-title">Clamp</p>
|
||||
<label class="field">
|
||||
<span>Label</span>
|
||||
<input class="inline-input" id="clamp-label" value="" />
|
||||
</label>
|
||||
<dl>
|
||||
<dt>x</dt><dd id="clamp-x"></dd>
|
||||
<dt>y</dt><dd id="clamp-y"></dd>
|
||||
<dt>frame</dt><dd id="clamp-frame"></dd>
|
||||
</dl>
|
||||
<p class="section-title">Cables through</p>
|
||||
${cablesHtml}
|
||||
<div class="inspector-actions">
|
||||
<button type="button" class="btn btn-danger btn-tiny" id="clamp-delete">Delete clamp</button>
|
||||
</div>
|
||||
`;
|
||||
body.querySelector("#clamp-label").value = cl.label;
|
||||
body.querySelector("#clamp-x").textContent = cl.x.toFixed(0);
|
||||
body.querySelector("#clamp-y").textContent = cl.y.toFixed(0);
|
||||
body.querySelector("#clamp-frame").textContent = frame ? frame.name : "—";
|
||||
|
||||
bindDebouncedRename(body.querySelector("#clamp-label"), async (label) => {
|
||||
if (!state.active) return;
|
||||
const updated = await patchClamp(state.active.id, cl.id, { label });
|
||||
Object.assign(cl, updated);
|
||||
renderCanvas();
|
||||
});
|
||||
|
||||
// Per-cable detach in the cables-through list.
|
||||
body.querySelectorAll(".clamp-detach").forEach((btn) => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!state.active) return;
|
||||
const cableID = Number(btn.getAttribute("data-cable-id"));
|
||||
try {
|
||||
await detachClampFromCable(state.active.id, cableID, cl.id);
|
||||
// Re-fetch snapshot fragment to keep ord contiguous.
|
||||
const snap = await getSnapshot(state.active.id);
|
||||
state.cableClamps = snap.cable_clamps || [];
|
||||
render();
|
||||
} catch (ex) {
|
||||
alert(`Detach failed: ${ex.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
body.querySelector("#clamp-delete").addEventListener("click", async () => {
|
||||
if (!state.active) return;
|
||||
const n = cablesThrough.length;
|
||||
const prompt = n > 0
|
||||
? `This clamp is on ${n} cable(s). Delete it and remove from all of them?`
|
||||
: "Delete this clamp?";
|
||||
if (!confirm(prompt)) return;
|
||||
try {
|
||||
await deleteClamp(state.active.id, cl.id);
|
||||
state.clamps = state.clamps.filter((c) => c.id !== id);
|
||||
state.cableClamps = state.cableClamps.filter((cc) => cc.clamp_id !== id);
|
||||
state.selection = null;
|
||||
render();
|
||||
} catch (ex) {
|
||||
alert(`Delete failed: ${ex.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Port editor — type / edge / label / delete. m can also navigate back
|
||||
// to the device by clicking "back to device" or anywhere on the device.
|
||||
function renderInspectorPort(body, id) {
|
||||
@@ -1568,6 +1714,8 @@ async function activateProject(id) {
|
||||
state.requirements = [];
|
||||
state.cables = [];
|
||||
state.bundles = [];
|
||||
state.clamps = [];
|
||||
state.cableClamps = [];
|
||||
state.selection = null;
|
||||
setActiveInURL(null);
|
||||
render();
|
||||
@@ -1584,6 +1732,8 @@ async function activateProject(id) {
|
||||
state.bundles = snap.bundles || [];
|
||||
state.requirements = snap.connection_requirements || [];
|
||||
state.cableTypes = snap.cable_types || [];
|
||||
state.clamps = snap.clamps || [];
|
||||
state.cableClamps = snap.cable_clamps || [];
|
||||
state.selection = null;
|
||||
setActiveInURL(id);
|
||||
// Hydrate the device-type catalog for this project — used by the
|
||||
@@ -1607,6 +1757,8 @@ async function activateProject(id) {
|
||||
state.requirements = [];
|
||||
state.cables = [];
|
||||
state.bundles = [];
|
||||
state.clamps = [];
|
||||
state.cableClamps = [];
|
||||
setActiveInURL(null);
|
||||
render();
|
||||
} else {
|
||||
@@ -1624,6 +1776,7 @@ function armTool(tool) {
|
||||
wrap.classList.toggle("tool-frame", tool === "frame");
|
||||
wrap.classList.toggle("tool-device", tool === "device");
|
||||
wrap.classList.toggle("tool-cable", tool === "cable");
|
||||
wrap.classList.toggle("tool-clamp", tool === "clamp");
|
||||
for (const btn of document.querySelectorAll("[data-tool]")) {
|
||||
btn.classList.toggle("armed", btn.getAttribute("data-tool") === tool);
|
||||
}
|
||||
@@ -1655,6 +1808,7 @@ function bindTools() {
|
||||
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");
|
||||
else if (e.key === "c" || e.key === "C") armTool("clamp");
|
||||
else if (e.key === "r" || e.key === "R") armTool("req");
|
||||
else if (e.key === "s" || e.key === "S") openSolveModal();
|
||||
});
|
||||
@@ -1714,6 +1868,11 @@ function onCanvasPointerDown(e) {
|
||||
placeDeviceAt(p);
|
||||
return;
|
||||
}
|
||||
if (state.tool === "clamp") {
|
||||
e.preventDefault();
|
||||
placeClampAt(p, e);
|
||||
return;
|
||||
}
|
||||
if (state.tool === "io") {
|
||||
e.preventDefault();
|
||||
placeIOMarkerAt(p);
|
||||
@@ -2239,6 +2398,40 @@ async function createPortFromForm(deviceID, typeID, edge, label) {
|
||||
}
|
||||
}
|
||||
|
||||
// + Clamp tool: drop a standalone routing anchor at the click. If the
|
||||
// click landed on a cable (slice 4 will detect this), the clamp will
|
||||
// also be attached to that cable mid-segment. For slice 3 we just
|
||||
// place it.
|
||||
async function placeClampAt(p, e) {
|
||||
if (!state.active) return;
|
||||
armTool(null);
|
||||
// Did the click hit a cable? If so, attach the new clamp to that cable.
|
||||
const cableEl = e && e.target instanceof Element
|
||||
? e.target.closest("[data-cable-id]")
|
||||
: null;
|
||||
const cableID = cableEl ? Number(cableEl.getAttribute("data-cable-id")) : null;
|
||||
const frame = frameAt(p.x, p.y);
|
||||
try {
|
||||
const c = await createClamp(state.active.id, {
|
||||
x: p.x, y: p.y,
|
||||
frame_id: frame ? frame.id : undefined,
|
||||
});
|
||||
state.clamps.push(c);
|
||||
if (cableID) {
|
||||
try {
|
||||
const cc = await attachClampToCable(state.active.id, cableID, { clamp_id: c.id });
|
||||
state.cableClamps.push(cc);
|
||||
} catch (ex) {
|
||||
alert(`Attach to cable failed: ${ex.message}`);
|
||||
}
|
||||
}
|
||||
state.selection = { kind: "clamp", id: c.id };
|
||||
render();
|
||||
} catch (err) {
|
||||
alert(`Create clamp failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function placeIOMarkerAt(p) {
|
||||
if (!state.active) return;
|
||||
armTool(null);
|
||||
@@ -2329,19 +2522,21 @@ function startDrag(e, kind, id) {
|
||||
|
||||
const svg = /** @type {SVGSVGElement} */ ($("#canvas"));
|
||||
const start = svgPoint(e);
|
||||
/** @type {Frame|Device|IOMarker|undefined} */
|
||||
/** @type {Frame|Device|IOMarker|Clamp|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);
|
||||
else if (kind === "clamp") obj = state.clamps.find((c) => c.id === id);
|
||||
if (!obj) return;
|
||||
const startX = obj.x;
|
||||
const startY = obj.y;
|
||||
|
||||
// For frame drags, remember the contained devices + IO markers + their
|
||||
// offsets so they follow the frame visually + persist on release.
|
||||
// For frame drags, remember the contained devices + IO markers + clamps
|
||||
// + 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}[]} */ ([]);
|
||||
let trackedClamps = /** @type {{c: Clamp, sx: number, sy: number}[]} */ ([]);
|
||||
if (kind === "frame") {
|
||||
for (const d of state.devices) {
|
||||
if (d.frame_id === obj.id) {
|
||||
@@ -2353,6 +2548,11 @@ function startDrag(e, kind, id) {
|
||||
trackedIOs.push({ m, sx: m.x, sy: m.y });
|
||||
}
|
||||
}
|
||||
for (const c of state.clamps) {
|
||||
if (c.frame_id === obj.id) {
|
||||
trackedClamps.push({ c, sx: c.x, sy: c.y });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the rect element NOW: by the time onUp fires async, the
|
||||
@@ -2376,6 +2576,7 @@ function startDrag(e, kind, id) {
|
||||
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; }
|
||||
for (const t of trackedClamps) { t.c.x = t.sx + dx; t.c.y = t.sy + dy; }
|
||||
}
|
||||
renderCanvas();
|
||||
};
|
||||
@@ -2392,12 +2593,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 + IO markers too.
|
||||
// Persist contained devices + IO markers + clamps too.
|
||||
await Promise.all([
|
||||
...trackedDevices.map((t) =>
|
||||
patchDevice(state.active.id, t.d.id, { x: t.d.x, y: t.d.y })),
|
||||
...trackedIOs.map((t) =>
|
||||
patchIOMarker(state.active.id, t.m.id, { x: t.m.x, y: t.m.y })),
|
||||
...trackedClamps.map((t) =>
|
||||
patchClamp(state.active.id, t.c.id, { x: t.c.x, y: t.c.y })),
|
||||
]);
|
||||
} else if (kind === "device") {
|
||||
const d = /** @type {Device} */ (obj);
|
||||
@@ -2412,7 +2615,7 @@ function startDrag(e, kind, id) {
|
||||
d.frame_id = newFrameID;
|
||||
}
|
||||
await patchDevice(state.active.id, d.id, patchBody);
|
||||
} else /* io */ {
|
||||
} else if (kind === "io") {
|
||||
const m = /** @type {IOMarker} */ (obj);
|
||||
const cx = m.x + IO_SIZE / 2;
|
||||
const cy = m.y + IO_SIZE / 2;
|
||||
@@ -2424,6 +2627,16 @@ function startDrag(e, kind, id) {
|
||||
m.frame_id = newFrameID;
|
||||
}
|
||||
await patchIOMarker(state.active.id, m.id, patchBody);
|
||||
} else /* clamp */ {
|
||||
const c = /** @type {Clamp} */ (obj);
|
||||
const targetFrame = frameAt(c.x, c.y);
|
||||
const newFrameID = targetFrame ? targetFrame.id : null;
|
||||
const patchBody = { x: c.x, y: c.y };
|
||||
if ((c.frame_id ?? null) !== newFrameID) {
|
||||
patchBody.frame_id = newFrameID;
|
||||
c.frame_id = newFrameID;
|
||||
}
|
||||
await patchClamp(state.active.id, c.id, patchBody);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Save failed: ${err.message}`);
|
||||
|
||||
@@ -227,9 +227,29 @@ body {
|
||||
.canvas-wrap.tool-device #canvas *,
|
||||
.canvas-wrap.tool-io #canvas,
|
||||
.canvas-wrap.tool-io #canvas *,
|
||||
.canvas-wrap.tool-clamp #canvas,
|
||||
.canvas-wrap.tool-clamp #canvas *,
|
||||
.canvas-wrap.tool-cable #canvas,
|
||||
.canvas-wrap.tool-cable #canvas * { cursor: crosshair !important; }
|
||||
|
||||
/* Clamps — small grey rounded squares (v5 §11). Cables route through
|
||||
them in `ord` sequence. */
|
||||
.clamp {
|
||||
fill: rgba(120, 120, 120, 0.85);
|
||||
stroke: rgba(40, 40, 40, 0.85);
|
||||
stroke-width: 1.5;
|
||||
cursor: grab;
|
||||
}
|
||||
.clamp.selected {
|
||||
stroke-width: 3;
|
||||
filter: drop-shadow(0 0 4px var(--accent));
|
||||
}
|
||||
.clamp-label {
|
||||
fill: var(--text-muted);
|
||||
font-size: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
|
||||
Reference in New Issue
Block a user