Skip to content

Commit 7fa35d3

Browse files
committed
Added node copy/paste support
1 parent af00f96 commit 7fa35d3

2 files changed

Lines changed: 232 additions & 0 deletions

File tree

RR Circuits API.bsdesign

1.19 KB
Binary file not shown.

Source/assets/js/Pages/editor.mjs

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,8 @@ $(function () {
280280
BASE_MINOR: 25,
281281
rafPending: false,
282282
_nextNodeId: 0,
283+
_nodeClipboard: null,
284+
_pasteSerial: 0,
283285
nodes: [],
284286
// Example node object structure:
285287
// {
@@ -340,6 +342,8 @@ $(function () {
340342

341343
// Enable Backspace/Delete to remove selected nodes.
342344
_.graph.node.setNodeDeletionHandler();
345+
// Enable Ctrl/Cmd+C/V/X for selected nodes.
346+
_.graph.node.setCopyPasteHandler();
343347

344348
// For testing: add a node to the center of the graph
345349
// await _.graph.node.add('Add Tag');
@@ -1260,6 +1264,79 @@ $(function () {
12601264
_.graph.data.connections = kept;
12611265
_.graph.functions.updateConnections();
12621266
},
1267+
addConnection: (fromNodeId, fromPortId, toNodeId, toPortId) => {
1268+
if (!fromNodeId || !fromPortId || !toNodeId || !toPortId) return false;
1269+
1270+
const layer = _.graph.functions._ensureWireLayer();
1271+
if (!layer) return false;
1272+
1273+
const fromPortEl0 = _.graph.functions._findPortEl(fromNodeId, fromPortId);
1274+
const toPortEl0 = _.graph.functions._findPortEl(toNodeId, toPortId);
1275+
if (!fromPortEl0 || !toPortEl0) return false;
1276+
1277+
let from = { nodeId: fromNodeId, portId: fromPortId, role: _.graph.functions._getPortRole(fromPortEl0) };
1278+
let to = { nodeId: toNodeId, portId: toPortId, role: _.graph.functions._getPortRole(toPortEl0) };
1279+
1280+
// Normalize direction: output -> input
1281+
if (from.role !== 'output' && to.role === 'output') {
1282+
[from, to] = [to, from];
1283+
}
1284+
1285+
const fromPortEl = _.graph.functions._findPortEl(from.nodeId, from.portId);
1286+
const toPortEl = _.graph.functions._findPortEl(to.nodeId, to.portId);
1287+
if (!fromPortEl || !toPortEl) return false;
1288+
1289+
from.role = _.graph.functions._getPortRole(fromPortEl);
1290+
to.role = _.graph.functions._getPortRole(toPortEl);
1291+
if (from.role && to.role && !(from.role === 'output' && to.role === 'input')) return false;
1292+
1293+
// No duplicate connections.
1294+
if ((_.graph.data.connections || []).some(c => c?.from?.nodeId === from.nodeId && c?.from?.portId === from.portId && c?.to?.nodeId === to.nodeId && c?.to?.portId === to.portId)) {
1295+
return false;
1296+
}
1297+
1298+
// Enforce exec-only connections.
1299+
const fromIsExec = _.graph.functions._isExecPortEl(fromPortEl);
1300+
const toIsExec = _.graph.functions._isExecPortEl(toPortEl);
1301+
if (fromIsExec !== toIsExec) return false;
1302+
1303+
// Enforce type compatibility.
1304+
const fromTypeInfo = _.graph.functions._getAllowedTypeSetForPort(from.nodeId, from.portId, fromPortEl);
1305+
const toTypeInfo = _.graph.functions._getAllowedTypeSetForPort(to.nodeId, to.portId, toPortEl);
1306+
if (!_.graph.functions._areTypesCompatible(fromTypeInfo, toTypeInfo)) return false;
1307+
1308+
// Enforce: data (non-exec) input ports can have only 1 incoming connection.
1309+
if (to.role === 'input' && !toIsExec) {
1310+
_.graph.functions.removeConnectionsToPort(to.nodeId, to.portId);
1311+
}
1312+
1313+
// Enforce: exec output ports can have only 1 connection.
1314+
if (from.role === 'output' && fromIsExec) {
1315+
_.graph.functions.removeConnectionsFromPort(from.nodeId, from.portId);
1316+
}
1317+
1318+
const svgNS = 'http://www.w3.org/2000/svg';
1319+
const path = document.createElementNS(svgNS, 'path');
1320+
path.setAttribute('fill', 'none');
1321+
const stroke = _.graph.functions._getWireStrokeForPorts(fromPortEl, toPortEl) || '#b7c7ff';
1322+
path.setAttribute('stroke', stroke);
1323+
path.setAttribute('stroke-width', String(_.graph.functions._getWireStrokeWidth()));
1324+
path.setAttribute('stroke-linecap', 'round');
1325+
path.setAttribute('opacity', '0.95');
1326+
path.style.filter = `drop-shadow(0 0 4px ${_.graph.functions._colorWithAlpha(stroke, 0.35)})`;
1327+
layer.wiresGroup.appendChild(path);
1328+
1329+
const connection = {
1330+
id: 'connection-' + (_.graph.data.connections?.length || 0),
1331+
from: { nodeId: from.nodeId, portId: from.portId },
1332+
to: { nodeId: to.nodeId, portId: to.portId },
1333+
element: path
1334+
};
1335+
if (!_.graph.data.connections) _.graph.data.connections = [];
1336+
_.graph.data.connections.push(connection);
1337+
_.graph.functions.updateConnections();
1338+
return true;
1339+
},
12631340
startConnection: (fromNodeId, fromPortId, startEvent) => {
12641341
const layer = _.graph.functions._ensureWireLayer();
12651342
if (!layer) return;
@@ -2002,10 +2079,165 @@ $(function () {
20022079

20032080
// Ensure the deletion handler is installed.
20042081
_.graph.node.setNodeDeletionHandler();
2082+
// Ensure copy/paste handler is installed.
2083+
_.graph.node.setCopyPasteHandler();
2084+
2085+
return nodeObject;
20052086

20062087
} else {
20072088
console.warn('Failed to render chip for node:', node);
2089+
return null;
2090+
}
2091+
},
2092+
_copySelectedNodesToClipboard: () => {
2093+
const selected = _.graph.functions.getSelectedNodes?.() || [];
2094+
if (selected.length === 0) return false;
2095+
2096+
const nodes = [];
2097+
let minX = Infinity;
2098+
let minY = Infinity;
2099+
2100+
for (const n of selected) {
2101+
const leftRaw = n?.element?.css?.('left');
2102+
const topRaw = n?.element?.css?.('top');
2103+
const x = Number.parseFloat(String(leftRaw || '0')) || 0;
2104+
const y = Number.parseFloat(String(topRaw || '0')) || 0;
2105+
2106+
minX = Math.min(minX, x);
2107+
minY = Math.min(minY, y);
2108+
2109+
nodes.push({
2110+
oldId: n.id,
2111+
payload: n.object ?? null,
2112+
x,
2113+
y
2114+
});
20082115
}
2116+
2117+
const selectedSet = new Set(selected.map(n => n.id));
2118+
const connections = (_.graph.data.connections || [])
2119+
.filter(c => selectedSet.has(c?.from?.nodeId) && selectedSet.has(c?.to?.nodeId))
2120+
.map(c => ({
2121+
fromNodeId: c.from.nodeId,
2122+
fromPortId: c.from.portId,
2123+
toNodeId: c.to.nodeId,
2124+
toPortId: c.to.portId
2125+
}));
2126+
2127+
_.graph.data._nodeClipboard = {
2128+
createdAt: Date.now(),
2129+
nodes: nodes.map(n => ({
2130+
oldId: n.oldId,
2131+
payload: n.payload,
2132+
relX: n.x - minX,
2133+
relY: n.y - minY
2134+
})),
2135+
connections
2136+
};
2137+
2138+
return true;
2139+
},
2140+
_pasteClipboard: async () => {
2141+
const clip = _.graph.data._nodeClipboard;
2142+
if (!clip || !Array.isArray(clip.nodes) || clip.nodes.length === 0) return false;
2143+
2144+
const vpEl = _.graph.data.elements.graphCanvasViewport.element;
2145+
if (!vpEl) return false;
2146+
2147+
// Paste near viewport center, with a small incremental offset.
2148+
const centerX = (vpEl.clientWidth * 0.5 - _.graph.data.cameraState.tx) / _.graph.data.cameraState.scale;
2149+
const centerY = (vpEl.clientHeight * 0.5 - _.graph.data.cameraState.ty) / _.graph.data.cameraState.scale;
2150+
const serial = Number(_.graph.data._pasteSerial || 0) + 1;
2151+
_.graph.data._pasteSerial = serial;
2152+
const delta = 20 * serial;
2153+
const baseX = centerX + delta;
2154+
const baseY = centerY + delta;
2155+
2156+
// Deselect existing nodes so pasted nodes become the active selection.
2157+
for (const n of (_.graph.data.nodes || [])) {
2158+
if (n?.selected) _.graph.functions.deselectNode(n.element);
2159+
}
2160+
2161+
const idMap = new Map();
2162+
const newIds = [];
2163+
2164+
for (const n of clip.nodes) {
2165+
const payload = n.payload ?? null;
2166+
// Prefer chipName when available; otherwise fall back to full object payload.
2167+
const addArg = payload?.chipName ? payload.chipName : payload;
2168+
const created = await _.graph.node.add(addArg);
2169+
if (!created?.id) continue;
2170+
2171+
idMap.set(n.oldId, created.id);
2172+
newIds.push(created.id);
2173+
2174+
await _.graph.node.setPosition(created.id, baseX + (Number(n.relX) || 0), baseY + (Number(n.relY) || 0));
2175+
}
2176+
2177+
// Recreate internal connections.
2178+
for (const c of (clip.connections || [])) {
2179+
const fromNodeId = idMap.get(c.fromNodeId);
2180+
const toNodeId = idMap.get(c.toNodeId);
2181+
if (!fromNodeId || !toNodeId) continue;
2182+
_.graph.functions.addConnection?.(fromNodeId, c.fromPortId, toNodeId, c.toPortId);
2183+
}
2184+
2185+
// Select pasted nodes.
2186+
for (const id of newIds) {
2187+
_.graph.functions.selectNode(id);
2188+
}
2189+
2190+
_.graph.functions.updateConnections?.();
2191+
return true;
2192+
},
2193+
setCopyPasteHandler: () => {
2194+
if (_.graph.data._copyPasteHandlerInstalled) return;
2195+
_.graph.data._copyPasteHandlerInstalled = true;
2196+
2197+
const handler = (e) => {
2198+
// Don't interfere while dragging a connection.
2199+
if (_.graph.data._connectionDrag?.active) return;
2200+
2201+
// Ignore while typing in inputs.
2202+
const ae = document.activeElement;
2203+
const tag = String(ae?.tagName || '').toLowerCase();
2204+
const isTyping = tag === 'input' || tag === 'textarea' || ae?.isContentEditable;
2205+
if (isTyping) return;
2206+
2207+
const ctrlOrCmd = !!(e.ctrlKey || e.metaKey);
2208+
if (!ctrlOrCmd) return;
2209+
2210+
const key = String(e.key || '').toLowerCase();
2211+
if (key !== 'c' && key !== 'v' && key !== 'x') return;
2212+
2213+
if (key === 'c') {
2214+
const did = _.graph.node._copySelectedNodesToClipboard();
2215+
if (!did) return;
2216+
e.preventDefault();
2217+
e.stopPropagation();
2218+
return;
2219+
}
2220+
2221+
if (key === 'x') {
2222+
const did = _.graph.node._copySelectedNodesToClipboard();
2223+
if (!did) return;
2224+
e.preventDefault();
2225+
e.stopPropagation();
2226+
const selected = _.graph.functions.getSelectedNodes?.() || [];
2227+
const ids = selected.map(n => n.id);
2228+
for (const id of ids) _.graph.functions.deleteNode(id);
2229+
return;
2230+
}
2231+
2232+
// Paste
2233+
if (!_.graph.data._nodeClipboard) return;
2234+
e.preventDefault();
2235+
e.stopPropagation();
2236+
Promise.resolve(_.graph.node._pasteClipboard()).catch(() => { /* ignore */ });
2237+
};
2238+
2239+
window.addEventListener('keydown', handler, true);
2240+
_.graph.data._copyPasteHandler = handler;
20092241
},
20102242
setNodeDeletionHandler: () => {
20112243
if (_.graph.data._nodeDeletionHandlerInstalled) return;

0 commit comments

Comments
 (0)