@@ -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