781 lines
27 KiB
JavaScript
781 lines
27 KiB
JavaScript
/* Patch editor: node graph with draggable wires, saved to /api/graph. */
|
|
"use strict";
|
|
|
|
(() => {
|
|
const NODE_W = 220;
|
|
const SOCKET_Y = 47; // socket centre below the node's top edge
|
|
const GRID = 24; // matches the editor background
|
|
const TYPES = {
|
|
client_source: { title: "Client Source", input: false, output: true },
|
|
client_sink: { title: "Client Sink", input: true, output: false },
|
|
public_sink: { title: "Public Sink", input: true, output: false },
|
|
splitter: { title: "Splitter", input: true, output: true },
|
|
tunnel_source: { title: "Tunnel Source", input: false, output: true },
|
|
tunnel_sink: { title: "Tunnel Sink", input: true, output: false },
|
|
};
|
|
const SOURCE_TYPES = new Set(["client_source", "tunnel_source"]);
|
|
const TUNNEL_TYPES = new Set(["tunnel_source", "tunnel_sink"]);
|
|
|
|
const editor = document.getElementById("editor");
|
|
const canvas = document.getElementById("canvas");
|
|
const wireLayer = document.getElementById("wire-layer");
|
|
const saveState = document.getElementById("save-state");
|
|
const SVG = "http://www.w3.org/2000/svg";
|
|
|
|
const S = {
|
|
nodes: new Map(), // id -> node
|
|
els: new Map(), // id -> element
|
|
links: [], // {from, to}
|
|
clients: [],
|
|
interfaces: {}, // host id ("0" = target) -> [{name, addr}]
|
|
status: { clients: {}, sinks: {} },
|
|
live: {},
|
|
daemon: true,
|
|
pan: { x: 40, y: 40 },
|
|
zoom: 1,
|
|
sel: null, // {kind: "nodes", ids: Set} | {kind: "link", link}
|
|
tidy: false, // snap nodes to the grid
|
|
nextTemp: -1,
|
|
dirty: false,
|
|
saving: false,
|
|
saveTimer: null,
|
|
addOffset: 0,
|
|
lastWire: null,
|
|
};
|
|
|
|
/* Graph helpers */
|
|
|
|
function inputLink(id) {
|
|
return S.links.find((l) => l.to === id);
|
|
}
|
|
|
|
// Follows splitters upstream to the client source feeding a node.
|
|
function sourceOf(id) {
|
|
let cur = id;
|
|
for (let i = 0; i < 64; i++) {
|
|
const n = S.nodes.get(cur);
|
|
if (!n) return null;
|
|
if (SOURCE_TYPES.has(n.type)) return n;
|
|
const l = inputLink(cur);
|
|
if (!l) return null;
|
|
cur = l.from;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function createsLoop(from, to) {
|
|
// Walking upstream from "from" must not reach "to".
|
|
let cur = from;
|
|
for (let i = 0; i < 64; i++) {
|
|
if (cur === to) return true;
|
|
const l = inputLink(cur);
|
|
if (!l) return false;
|
|
cur = l.from;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/* View transform */
|
|
|
|
function applyView() {
|
|
const t = `translate(${S.pan.x}px, ${S.pan.y}px) scale(${S.zoom})`;
|
|
canvas.style.transform = t;
|
|
wireLayer.setAttribute("transform", `translate(${S.pan.x} ${S.pan.y}) scale(${S.zoom})`);
|
|
// The grid moves with the canvas so tidy nodes sit on its lines.
|
|
editor.style.backgroundSize = `${GRID * S.zoom}px ${GRID * S.zoom}px`;
|
|
editor.style.backgroundPosition = `${S.pan.x}px ${S.pan.y}px`;
|
|
}
|
|
|
|
function snap(v) {
|
|
return Math.round(v / GRID) * GRID;
|
|
}
|
|
|
|
function toCanvas(clientX, clientY) {
|
|
const r = editor.getBoundingClientRect();
|
|
return { x: (clientX - r.left - S.pan.x) / S.zoom, y: (clientY - r.top - S.pan.y) / S.zoom };
|
|
}
|
|
|
|
function fit() {
|
|
if (!S.nodes.size) return;
|
|
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
for (const n of S.nodes.values()) {
|
|
const el = S.els.get(n.id);
|
|
x0 = Math.min(x0, n.x);
|
|
y0 = Math.min(y0, n.y);
|
|
x1 = Math.max(x1, n.x + NODE_W);
|
|
y1 = Math.max(y1, n.y + (el ? el.offsetHeight : 150));
|
|
}
|
|
const r = editor.getBoundingClientRect();
|
|
const pad = 60;
|
|
S.zoom = Math.max(0.3, Math.min(1.2, Math.min((r.width - pad * 2) / (x1 - x0), (r.height - pad * 2) / (y1 - y0))));
|
|
S.pan.x = (r.width - (x1 - x0) * S.zoom) / 2 - x0 * S.zoom;
|
|
S.pan.y = (r.height - (y1 - y0) * S.zoom) / 2 - y0 * S.zoom;
|
|
applyView();
|
|
}
|
|
|
|
/* Wires */
|
|
|
|
function wirePath(x1, y1, x2, y2) {
|
|
const dx = Math.max(40, Math.abs(x2 - x1) * 0.5);
|
|
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
|
}
|
|
|
|
function outPos(n) { return { x: n.x + NODE_W + 1, y: n.y + SOCKET_Y }; }
|
|
function inPos(n) { return { x: n.x - 1, y: n.y + SOCKET_Y }; }
|
|
|
|
function drawWires() {
|
|
wireLayer.replaceChildren();
|
|
for (const l of S.links) {
|
|
const a = S.nodes.get(l.from), b = S.nodes.get(l.to);
|
|
if (!a || !b) continue;
|
|
const p = outPos(a), q = inPos(b);
|
|
const d = wirePath(p.x, p.y, q.x, q.y);
|
|
const src = sourceOf(l.from);
|
|
let cls = src ? src.proto : "none";
|
|
if (src && b.type !== "splitter" && b.proto !== src.proto) cls += " bad";
|
|
if (S.sel && S.sel.kind === "link" && S.sel.link === l) cls += " selected";
|
|
|
|
const vis = document.createElementNS(SVG, "path");
|
|
vis.setAttribute("d", d);
|
|
vis.setAttribute("class", cls);
|
|
const hit = document.createElementNS(SVG, "path");
|
|
hit.setAttribute("d", d);
|
|
hit.setAttribute("class", "hit");
|
|
hit.addEventListener("mousedown", (ev) => {
|
|
ev.stopPropagation();
|
|
// Selecting re-renders the wires, so dblclick would never fire; time it instead.
|
|
const now = Date.now();
|
|
if (S.lastWire && S.lastWire.link === l && now - S.lastWire.t < 400) {
|
|
S.lastWire = null;
|
|
removeLink(l);
|
|
return;
|
|
}
|
|
S.lastWire = { link: l, t: now };
|
|
select({ kind: "link", link: l });
|
|
editor.focus();
|
|
});
|
|
wireLayer.append(vis, hit);
|
|
|
|
// A selected wire gets a delete button at its midpoint (bezier t = 0.5).
|
|
if (S.sel && S.sel.kind === "link" && S.sel.link === l) {
|
|
const dx = Math.max(40, Math.abs(q.x - p.x) * 0.5);
|
|
const mx = (p.x + 3 * (p.x + dx) + 3 * (q.x - dx) + q.x) / 8;
|
|
const my = (p.y + q.y) / 2;
|
|
const btn = document.createElementNS(SVG, "g");
|
|
btn.setAttribute("class", "wire-del");
|
|
btn.setAttribute("transform", `translate(${mx} ${my})`);
|
|
const title = document.createElementNS(SVG, "title");
|
|
title.textContent = "Remove connection";
|
|
const circle = document.createElementNS(SVG, "circle");
|
|
circle.setAttribute("r", "10");
|
|
const x = document.createElementNS(SVG, "path");
|
|
x.setAttribute("d", "M -4 -4 L 4 4 M 4 -4 L -4 4");
|
|
btn.append(title, circle, x);
|
|
btn.addEventListener("mousedown", (ev) => ev.stopPropagation());
|
|
btn.addEventListener("click", (ev) => {
|
|
ev.stopPropagation();
|
|
removeLink(l);
|
|
});
|
|
wireLayer.append(btn);
|
|
}
|
|
}
|
|
}
|
|
|
|
function removeLink(l) {
|
|
S.links = S.links.filter((x) => x !== l);
|
|
if (S.sel && S.sel.kind === "link" && S.sel.link === l) S.sel = null;
|
|
changed();
|
|
}
|
|
|
|
/* Nodes */
|
|
|
|
function field(label, input) {
|
|
return PB.el("label", {}, PB.el("span", { text: label }), input);
|
|
}
|
|
|
|
// Tunnel nodes may live on the target itself (client_id null).
|
|
function clientSelect(n, onChange) {
|
|
const sel = PB.el("select", { "aria-label": "Host" });
|
|
sel.append(PB.el("option", { value: "", text: TUNNEL_TYPES.has(n.type) ? "Target (this server)" : "- choose client -" }));
|
|
for (const c of S.clients) {
|
|
const o = PB.el("option", { value: String(c.id), text: c.name });
|
|
if (n.client_id === c.id) o.selected = true;
|
|
sel.append(o);
|
|
}
|
|
sel.addEventListener("change", () => {
|
|
n.client_id = sel.value ? Number(sel.value) : null;
|
|
if (onChange) onChange();
|
|
changed();
|
|
});
|
|
return sel;
|
|
}
|
|
|
|
// Free text with suggestions from the tun interfaces the host reported.
|
|
function ifaceInput(n) {
|
|
const listId = `ifaces-${Math.random().toString(36).slice(2)}`;
|
|
const list = PB.el("datalist", { id: listId });
|
|
const inp = PB.el("input", { value: n.iface || "", placeholder: "tun0", list: listId, spellcheck: "false" });
|
|
inp.addEventListener("input", () => { n.iface = inp.value.trim(); changed(); });
|
|
const fill = () => {
|
|
list.replaceChildren();
|
|
for (const i of S.interfaces[String(n.client_id || 0)] || []) {
|
|
list.append(PB.el("option", { value: i.name, text: i.addr !== "-" ? `${i.name} (${i.addr})` : i.name }));
|
|
}
|
|
};
|
|
fill();
|
|
const wrap = PB.el("span", { class: "iface-wrap" }, inp, list);
|
|
wrap._fill = fill;
|
|
return wrap;
|
|
}
|
|
|
|
function textInput(n, key, placeholder) {
|
|
const inp = PB.el("input", { value: n[key] || "", placeholder: placeholder || "", spellcheck: "false" });
|
|
inp.addEventListener("input", () => { n[key] = inp.value.trim(); changed(); });
|
|
return inp;
|
|
}
|
|
|
|
function portInput(n) {
|
|
const inp = PB.el("input", { type: "number", min: "1", max: "65535", value: n.port ? String(n.port) : "", placeholder: "port" });
|
|
inp.addEventListener("input", () => { n.port = Number(inp.value) || 0; changed(); });
|
|
return inp;
|
|
}
|
|
|
|
function protoSelect(n) {
|
|
const sel = PB.el("select", { "aria-label": "Protocol" });
|
|
for (const p of ["tcp", "udp"]) {
|
|
const o = PB.el("option", { value: p, text: p.toUpperCase() });
|
|
if (n.proto === p) o.selected = true;
|
|
sel.append(o);
|
|
}
|
|
sel.addEventListener("change", () => { n.proto = sel.value; changed(); });
|
|
return sel;
|
|
}
|
|
|
|
// How a sink passes the peer address on. Both options are exclusive, and
|
|
// spoofing needs the source on a client (the daemon reports it otherwise).
|
|
function originChecks(n) {
|
|
const box = (label, value, title) => {
|
|
const inp = PB.el("input", { type: "checkbox" });
|
|
const lab = PB.el("label", { class: "check", title }, inp, PB.el("span", { text: label }));
|
|
inp.addEventListener("change", () => {
|
|
n.origin = inp.checked ? value : "";
|
|
update();
|
|
changed();
|
|
});
|
|
return { inp, lab, value };
|
|
};
|
|
const proxy = box("PROXY v2", "proxy_v2",
|
|
"Send a PROXY protocol v2 header with the peer address to the service (e.g. Apache mod_remoteip, nginx proxy_protocol)");
|
|
const spoof = box("Transparent source spoofing", "transparent",
|
|
"Connect to the service from the peer's own address (IP_TRANSPARENT); the source must be on a client");
|
|
function update() {
|
|
const src = sourceOf(n.id);
|
|
const onClient = !src || !!src.client_id;
|
|
for (const b of [proxy, spoof]) {
|
|
b.inp.checked = n.origin === b.value;
|
|
let off = !!n.origin && !b.inp.checked;
|
|
if (b === spoof && !b.inp.checked && !onClient) off = true;
|
|
b.inp.disabled = off;
|
|
b.lab.classList.toggle("disabled", off);
|
|
}
|
|
}
|
|
update();
|
|
const wrap = PB.el("div", { class: "origin" }, proxy.lab, spoof.lab);
|
|
wrap._update = update;
|
|
return wrap;
|
|
}
|
|
|
|
function buildNode(n) {
|
|
const t = TYPES[n.type];
|
|
const el = PB.el("div", { class: `node ${n.type}` });
|
|
const title = PB.el("span", { class: "title", text: t.title });
|
|
const close = PB.el("button", { class: "x", type: "button", title: "Delete node", "aria-label": "Delete node", text: "\u00d7" });
|
|
const head = PB.el("div", { class: "node-head" }, title, close);
|
|
const body = PB.el("div", { class: "node-body" });
|
|
|
|
if (n.type === "client_source") {
|
|
body.append(field("Client", clientSelect(n)), field("Address", textInput(n, "host", "127.0.0.1")),
|
|
field("Port", portInput(n)), field("Proto", protoSelect(n)));
|
|
} else if (n.type === "client_sink") {
|
|
body.append(field("Client", clientSelect(n)), field("Bind", textInput(n, "host", "127.0.0.1")),
|
|
field("Port", portInput(n)), field("Proto", protoSelect(n)));
|
|
} else if (n.type === "public_sink") {
|
|
body.append(field("Bind", textInput(n, "host", "0.0.0.0")), field("Port", portInput(n)), field("Proto", protoSelect(n)));
|
|
} else if (TUNNEL_TYPES.has(n.type)) {
|
|
const iface = ifaceInput(n);
|
|
body.append(field("Host", clientSelect(n, iface._fill)), field("Iface", iface));
|
|
if (n.type === "tunnel_source") body.append(field("Peer", textInput(n, "host", "e.g. 10.100.0.2")));
|
|
body.append(field("Port", portInput(n)), field("Proto", protoSelect(n)));
|
|
}
|
|
body.append(field("Label", textInput(n, "label", "optional")));
|
|
let origin = null;
|
|
if (t.input && n.type !== "splitter") {
|
|
origin = originChecks(n);
|
|
body.append(origin);
|
|
}
|
|
const status = PB.el("div", { class: "node-status" });
|
|
body.append(status);
|
|
el.append(head, body);
|
|
if (t.input) el.append(PB.el("div", { class: "socket in", "data-node": String(n.id), title: "Input" }));
|
|
if (t.output) el.append(PB.el("div", { class: "socket out", "data-node": String(n.id), title: "Output" }));
|
|
|
|
close.addEventListener("mousedown", (ev) => ev.stopPropagation());
|
|
close.addEventListener("click", () => removeNode(n.id));
|
|
head.addEventListener("mousedown", (ev) => startNodeDrag(ev, n));
|
|
el.addEventListener("mousedown", (ev) => {
|
|
// Sockets must bubble up to the canvas handler that starts wire drags,
|
|
// right clicks to the editor that draws the selection box.
|
|
if (ev.button === 2 || ev.target.closest("input, select, button, .socket")) return;
|
|
ev.stopPropagation();
|
|
if (!isSelected(n.id)) selectNodes([n.id]);
|
|
});
|
|
el._status = status;
|
|
el._title = title;
|
|
el._origin = origin;
|
|
return el;
|
|
}
|
|
|
|
function placeNode(n) {
|
|
const el = S.els.get(n.id);
|
|
el.style.left = n.x + "px";
|
|
el.style.top = n.y + "px";
|
|
}
|
|
|
|
function renderAll() {
|
|
canvas.replaceChildren();
|
|
S.els.clear();
|
|
for (const n of S.nodes.values()) {
|
|
const el = buildNode(n);
|
|
S.els.set(n.id, el);
|
|
canvas.append(el);
|
|
placeNode(n);
|
|
}
|
|
// Socket data attributes carry ids; refresh after remapping.
|
|
updateStatus();
|
|
drawWires();
|
|
}
|
|
|
|
function addNode(type) {
|
|
const r = editor.getBoundingClientRect();
|
|
const c = toCanvas(r.left + r.width / 2, r.top + r.height / 2);
|
|
const off = (S.addOffset++ % 6) * GRID;
|
|
const n = {
|
|
id: S.nextTemp--, type, client_id: null, port: 0, proto: "tcp", label: "", origin: "",
|
|
iface: TUNNEL_TYPES.has(type) ? "tun0" : "",
|
|
host: type === "public_sink" ? "0.0.0.0" : (type === "splitter" || TUNNEL_TYPES.has(type)) ? "" : "127.0.0.1",
|
|
x: Math.round(c.x - NODE_W / 2 + off), y: Math.round(c.y - 80 + off),
|
|
};
|
|
if (S.tidy) {
|
|
n.x = snap(n.x);
|
|
n.y = snap(n.y);
|
|
}
|
|
S.nodes.set(n.id, n);
|
|
const el = buildNode(n);
|
|
S.els.set(n.id, el);
|
|
canvas.append(el);
|
|
placeNode(n);
|
|
selectNodes([n.id]);
|
|
changed();
|
|
}
|
|
|
|
function removeNode(id) {
|
|
S.nodes.delete(id);
|
|
const el = S.els.get(id);
|
|
if (el) el.remove();
|
|
S.els.delete(id);
|
|
S.links = S.links.filter((l) => l.from !== id && l.to !== id);
|
|
if (isSelected(id)) {
|
|
S.sel.ids.delete(id);
|
|
if (!S.sel.ids.size) S.sel = null;
|
|
}
|
|
drawWires();
|
|
changed();
|
|
}
|
|
|
|
function isSelected(id) {
|
|
return !!S.sel && S.sel.kind === "nodes" && S.sel.ids.has(id);
|
|
}
|
|
|
|
function select(sel) {
|
|
S.sel = sel;
|
|
for (const [id, el] of S.els) el.classList.toggle("selected", isSelected(id));
|
|
drawWires();
|
|
}
|
|
|
|
function selectNodes(ids) {
|
|
select(ids.length ? { kind: "nodes", ids: new Set(ids) } : null);
|
|
}
|
|
|
|
/* Status */
|
|
|
|
function updateStatus() {
|
|
for (const n of S.nodes.values()) {
|
|
const el = S.els.get(n.id);
|
|
if (!el) continue;
|
|
el._title.textContent = TYPES[n.type].title + (n.id > 0 ? ` #${n.id}` : "");
|
|
if (el._origin) el._origin._update();
|
|
const st = el._status;
|
|
st.replaceChildren();
|
|
st.classList.remove("err");
|
|
const parts = [];
|
|
|
|
if (n.client_id && n.type !== "splitter" && n.type !== "public_sink") {
|
|
const on = !!S.status.clients[String(n.client_id)];
|
|
parts.push(PB.el("span", { class: `dot ${on ? "on" : "off"}` }), PB.el("span", { text: on ? "online" : "offline" }));
|
|
}
|
|
if (n.type === "splitter") {
|
|
const outs = S.links.filter((l) => l.from === n.id).length;
|
|
parts.push(PB.el("span", { text: `fans out to ${outs} sink${outs === 1 ? "" : "s"}` }));
|
|
}
|
|
if (TYPES[n.type].input && n.type !== "splitter" && n.id > 0 && !S.dirty) {
|
|
const s = S.status.sinks[String(n.id)];
|
|
const lv = S.live[String(n.id)];
|
|
if (s && !s.ok) {
|
|
st.classList.add("err");
|
|
parts.push(PB.el("span", { text: s.error }));
|
|
} else if (s) {
|
|
const txt = lv ? `in ${PB.rate(lv.in)}, out ${PB.rate(lv.out)}, ${lv.active} open` : "ready";
|
|
parts.push(PB.el("span", { text: s.source_online ? txt : "source offline" }));
|
|
}
|
|
}
|
|
if (!S.daemon && n.type !== "splitter") {
|
|
st.classList.add("err");
|
|
parts.length = 0;
|
|
parts.push(PB.el("span", { text: "daemon not reachable" }));
|
|
}
|
|
st.append(...parts);
|
|
}
|
|
}
|
|
|
|
async function pollStatus() {
|
|
try {
|
|
const [st, lv] = await Promise.all([PB.api("GET", "/api/status"), PB.api("GET", "/api/live")]);
|
|
S.daemon = st.daemon;
|
|
S.status = { clients: st.clients || {}, sinks: st.sinks || {} };
|
|
S.live = lv.sinks || {};
|
|
} catch (e) {
|
|
S.daemon = false;
|
|
}
|
|
updateStatus();
|
|
}
|
|
|
|
/* Saving */
|
|
|
|
function setSaveState(text, err) {
|
|
saveState.textContent = text;
|
|
saveState.classList.toggle("err", !!err);
|
|
}
|
|
|
|
function changed() {
|
|
S.dirty = true;
|
|
setSaveState("Unsaved changes");
|
|
clearTimeout(S.saveTimer);
|
|
S.saveTimer = setTimeout(save, 700);
|
|
drawWires();
|
|
updateStatus();
|
|
}
|
|
|
|
async function save() {
|
|
if (S.saving) {
|
|
S.saveTimer = setTimeout(save, 300);
|
|
return;
|
|
}
|
|
S.saving = true;
|
|
S.dirty = false;
|
|
setSaveState("Saving...");
|
|
const payload = {
|
|
nodes: [...S.nodes.values()].map((n) => ({ ...n })),
|
|
links: S.links.map((l) => ({ from: l.from, to: l.to })),
|
|
};
|
|
try {
|
|
const res = await PB.api("PUT", "/api/graph", payload);
|
|
remapIds(res.ids || {});
|
|
setSaveState(res.daemon ? "Saved" : "Saved (daemon offline)", !res.daemon);
|
|
await pollStatus();
|
|
} catch (e) {
|
|
S.dirty = true;
|
|
setSaveState("Not saved: " + e.message, true);
|
|
} finally {
|
|
S.saving = false;
|
|
}
|
|
}
|
|
|
|
function remapIds(ids) {
|
|
let any = false;
|
|
for (const [oldStr, newId] of Object.entries(ids)) {
|
|
const oldId = Number(oldStr);
|
|
if (oldId === newId || !S.nodes.has(oldId)) continue;
|
|
const n = S.nodes.get(oldId);
|
|
S.nodes.delete(oldId);
|
|
n.id = newId;
|
|
S.nodes.set(newId, n);
|
|
for (const l of S.links) {
|
|
if (l.from === oldId) l.from = newId;
|
|
if (l.to === oldId) l.to = newId;
|
|
}
|
|
if (isSelected(oldId)) {
|
|
S.sel.ids.delete(oldId);
|
|
S.sel.ids.add(newId);
|
|
}
|
|
any = true;
|
|
}
|
|
if (any) renderAll();
|
|
}
|
|
|
|
/* Mouse interaction */
|
|
|
|
function startNodeDrag(ev, n) {
|
|
if (ev.button !== 0 || ev.target.closest("button")) return;
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
// Dragging a member of a multi-selection moves the whole group.
|
|
if (!isSelected(n.id)) selectNodes([n.id]);
|
|
const group = [...S.sel.ids].map((id) => S.nodes.get(id)).filter(Boolean).map((g) => ({ g, x: g.x, y: g.y }));
|
|
const start = toCanvas(ev.clientX, ev.clientY);
|
|
const ox = n.x, oy = n.y;
|
|
let moved = false;
|
|
const move = (e) => {
|
|
const p = toCanvas(e.clientX, e.clientY);
|
|
let dx = Math.round(p.x - start.x), dy = Math.round(p.y - start.y);
|
|
// Tidy snaps the grabbed node; the others keep their offset to it.
|
|
if (S.tidy) {
|
|
dx = snap(ox + dx) - ox;
|
|
dy = snap(oy + dy) - oy;
|
|
}
|
|
for (const m of group) {
|
|
m.g.x = m.x + dx;
|
|
m.g.y = m.y + dy;
|
|
placeNode(m.g);
|
|
}
|
|
moved = true;
|
|
drawWires();
|
|
};
|
|
const up = () => {
|
|
window.removeEventListener("mousemove", move);
|
|
window.removeEventListener("mouseup", up);
|
|
if (moved) changed();
|
|
};
|
|
window.addEventListener("mousemove", move);
|
|
window.addEventListener("mouseup", up);
|
|
}
|
|
|
|
function startWireDrag(ev, fromId) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
const from = S.nodes.get(fromId);
|
|
const temp = document.createElementNS(SVG, "path");
|
|
temp.setAttribute("class", "temp");
|
|
wireLayer.append(temp);
|
|
let target = null;
|
|
|
|
const move = (e) => {
|
|
const p = outPos(from), q = toCanvas(e.clientX, e.clientY);
|
|
temp.setAttribute("d", wirePath(p.x, p.y, q.x, q.y));
|
|
const hit = document.elementFromPoint(e.clientX, e.clientY);
|
|
const sock = hit && hit.closest(".socket.in");
|
|
if (target && target !== sock) target.classList.remove("target");
|
|
target = sock && Number(sock.dataset.node) !== fromId ? sock : null;
|
|
if (target) target.classList.add("target");
|
|
};
|
|
const up = () => {
|
|
window.removeEventListener("mousemove", move);
|
|
window.removeEventListener("mouseup", up);
|
|
temp.remove();
|
|
if (target) {
|
|
target.classList.remove("target");
|
|
connect(fromId, Number(target.dataset.node));
|
|
}
|
|
drawWires();
|
|
};
|
|
move(ev);
|
|
window.addEventListener("mousemove", move);
|
|
window.addEventListener("mouseup", up);
|
|
}
|
|
|
|
function connect(from, to) {
|
|
if (from === to || createsLoop(from, to)) {
|
|
setSaveState("That link would create a loop", true);
|
|
return;
|
|
}
|
|
S.links = S.links.filter((l) => l.to !== to);
|
|
// A source has a single output; fan-out goes through a splitter.
|
|
if (SOURCE_TYPES.has(S.nodes.get(from).type)) S.links = S.links.filter((l) => l.from !== from);
|
|
S.links.push({ from, to });
|
|
changed();
|
|
}
|
|
|
|
canvas.addEventListener("mousedown", (ev) => {
|
|
const sock = ev.target.closest(".socket");
|
|
if (!sock || ev.button !== 0) return;
|
|
const id = Number(sock.dataset.node);
|
|
if (sock.classList.contains("out")) {
|
|
startWireDrag(ev, id);
|
|
} else {
|
|
// Grabbing a connected input detaches the wire and keeps dragging it.
|
|
const l = inputLink(id);
|
|
if (l) {
|
|
removeLink(l);
|
|
startWireDrag(ev, l.from);
|
|
} else {
|
|
ev.stopPropagation();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Right-drag draws a selection box; nodes touching it are selected.
|
|
// Shift keeps the current selection and adds to it.
|
|
function startMarquee(ev) {
|
|
ev.preventDefault();
|
|
editor.focus();
|
|
const r = editor.getBoundingClientRect();
|
|
const sx = ev.clientX - r.left, sy = ev.clientY - r.top;
|
|
const base = ev.shiftKey && S.sel && S.sel.kind === "nodes" ? [...S.sel.ids] : [];
|
|
const box = PB.el("div", { class: "marquee" });
|
|
editor.append(box);
|
|
const move = (e) => {
|
|
const x = e.clientX - r.left, y = e.clientY - r.top;
|
|
const x0 = Math.min(sx, x), y0 = Math.min(sy, y), w = Math.abs(x - sx), h = Math.abs(y - sy);
|
|
box.style.left = x0 + "px";
|
|
box.style.top = y0 + "px";
|
|
box.style.width = w + "px";
|
|
box.style.height = h + "px";
|
|
const a = toCanvas(r.left + x0, r.top + y0), b = toCanvas(r.left + x0 + w, r.top + y0 + h);
|
|
const ids = new Set(base);
|
|
for (const n of S.nodes.values()) {
|
|
const el = S.els.get(n.id);
|
|
const nh = el ? el.offsetHeight : 150;
|
|
if (n.x < b.x && n.x + NODE_W > a.x && n.y < b.y && n.y + nh > a.y) ids.add(n.id);
|
|
}
|
|
selectNodes([...ids]);
|
|
};
|
|
const up = (e) => {
|
|
if (e.button !== 2) return;
|
|
box.remove();
|
|
window.removeEventListener("mousemove", move);
|
|
window.removeEventListener("mouseup", up);
|
|
};
|
|
move(ev);
|
|
window.addEventListener("mousemove", move);
|
|
window.addEventListener("mouseup", up);
|
|
}
|
|
|
|
editor.addEventListener("contextmenu", (ev) => {
|
|
if (!ev.target.closest("input, select, textarea")) ev.preventDefault();
|
|
});
|
|
|
|
editor.addEventListener("mousedown", (ev) => {
|
|
if (ev.button === 2) {
|
|
if (!ev.target.closest("input, select, textarea")) startMarquee(ev);
|
|
return;
|
|
}
|
|
if (ev.button !== 0 && ev.button !== 1) return;
|
|
if (ev.target.closest(".node")) return;
|
|
ev.preventDefault();
|
|
editor.focus();
|
|
select(null);
|
|
const sx = ev.clientX, sy = ev.clientY, px = S.pan.x, py = S.pan.y;
|
|
editor.classList.add("panning");
|
|
const move = (e) => {
|
|
S.pan.x = px + e.clientX - sx;
|
|
S.pan.y = py + e.clientY - sy;
|
|
applyView();
|
|
};
|
|
const up = () => {
|
|
editor.classList.remove("panning");
|
|
window.removeEventListener("mousemove", move);
|
|
window.removeEventListener("mouseup", up);
|
|
};
|
|
window.addEventListener("mousemove", move);
|
|
window.addEventListener("mouseup", up);
|
|
});
|
|
|
|
editor.addEventListener("wheel", (ev) => {
|
|
if (ev.target.closest("select")) return;
|
|
ev.preventDefault();
|
|
const r = editor.getBoundingClientRect();
|
|
const mx = ev.clientX - r.left, my = ev.clientY - r.top;
|
|
const z = Math.max(0.3, Math.min(2, S.zoom * (ev.deltaY < 0 ? 1.1 : 1 / 1.1)));
|
|
S.pan.x = mx - (mx - S.pan.x) * (z / S.zoom);
|
|
S.pan.y = my - (my - S.pan.y) * (z / S.zoom);
|
|
S.zoom = z;
|
|
applyView();
|
|
}, { passive: false });
|
|
|
|
editor.addEventListener("keydown", (ev) => {
|
|
if (ev.target.closest("input, select, textarea")) return;
|
|
if ((ev.key === "Delete" || ev.key === "Backspace") && S.sel) {
|
|
ev.preventDefault();
|
|
if (S.sel.kind === "nodes") [...S.sel.ids].forEach(removeNode);
|
|
else removeLink(S.sel.link);
|
|
}
|
|
});
|
|
|
|
/* Toolbar */
|
|
|
|
const addMenu = document.getElementById("add-menu");
|
|
const addBtn = document.getElementById("add-btn");
|
|
function setMenu(open) {
|
|
addMenu.classList.toggle("open", open);
|
|
addBtn.setAttribute("aria-expanded", String(open));
|
|
}
|
|
addBtn.addEventListener("click", () => setMenu(!addMenu.classList.contains("open")));
|
|
addMenu.querySelectorAll("[data-add]").forEach((b) => b.addEventListener("click", () => {
|
|
setMenu(false);
|
|
addNode(b.dataset.add);
|
|
}));
|
|
document.addEventListener("click", (ev) => { if (!addMenu.contains(ev.target)) setMenu(false); });
|
|
document.addEventListener("keydown", (ev) => { if (ev.key === "Escape") setMenu(false); });
|
|
document.getElementById("fit-btn").addEventListener("click", fit);
|
|
|
|
// Tidy: snaps every node to the grid when switched on and keeps drags and
|
|
// new nodes on it. The switch is remembered per browser.
|
|
const tidyBtn = document.getElementById("tidy-btn");
|
|
function setTidy(on, apply) {
|
|
S.tidy = on;
|
|
tidyBtn.setAttribute("aria-pressed", String(on));
|
|
try { localStorage.setItem("pb-tidy", on ? "1" : "0"); } catch (e) { /* storage blocked */ }
|
|
if (!on || !apply) return;
|
|
let moved = false;
|
|
for (const n of S.nodes.values()) {
|
|
const x = snap(n.x), y = snap(n.y);
|
|
if (x === n.x && y === n.y) continue;
|
|
n.x = x;
|
|
n.y = y;
|
|
placeNode(n);
|
|
moved = true;
|
|
}
|
|
if (moved) changed();
|
|
}
|
|
tidyBtn.addEventListener("click", () => setTidy(!S.tidy, true));
|
|
try { setTidy(localStorage.getItem("pb-tidy") === "1", false); } catch (e) { /* storage blocked */ }
|
|
|
|
window.addEventListener("beforeunload", (ev) => {
|
|
if (S.dirty || S.saving) {
|
|
save();
|
|
ev.preventDefault();
|
|
}
|
|
});
|
|
|
|
/* Start */
|
|
|
|
async function load() {
|
|
try {
|
|
const g = await PB.api("GET", "/api/graph");
|
|
S.clients = g.clients;
|
|
S.interfaces = g.interfaces || {};
|
|
for (const n of g.nodes) S.nodes.set(n.id, n);
|
|
S.links = g.links;
|
|
renderAll();
|
|
applyView();
|
|
fit();
|
|
setSaveState(S.nodes.size ? "Saved" : "Empty patch: use Add to create nodes");
|
|
} catch (e) {
|
|
setSaveState("Loading failed: " + e.message, true);
|
|
}
|
|
await pollStatus();
|
|
setInterval(pollStatus, 3000);
|
|
}
|
|
|
|
load();
|
|
})();
|