@@ -33,11 +33,12 @@ import Stack from "@mui/material/Stack";
3333import Button from "@mui/material/Button" ;
3434import CircleIcon from "@mui/icons-material/Circle" ;
3535import CheckCircleIcon from "@mui/icons-material/CheckCircle" ;
36+ import ContentCopyIcon from "@mui/icons-material/ContentCopy" ;
3637import Grid from "@mui/material/Grid" ;
3738import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline" ;
3839import DeleteIcon from "@mui/icons-material/Delete" ;
3940import ViewComfyIcon from "@mui/icons-material/ViewComfy" ;
40-
41+ import { CopyToClipboard } from "react-copy-to-clipboard" ;
4142import Moveable from "react-moveable" ;
4243import { ResizableBox } from "react-resizable" ;
4344import Ansi from "ansi-to-react" ;
@@ -48,7 +49,11 @@ import { lowercase, numbers } from "nanoid-dictionary";
4849import { useStore } from "zustand" ;
4950
5051import { RepoContext , RoleType } from "../lib/store" ;
51- import { useNodesStateSynced , parent as commonParent } from "../lib/nodes" ;
52+ import {
53+ useNodesStateSynced ,
54+ resetSelection ,
55+ parent as commonParent ,
56+ } from "../lib/nodes" ;
5257
5358import { MyMonaco } from "./MyMonaco" ;
5459import { useApolloClient } from "@apollo/client" ;
@@ -525,6 +530,22 @@ const CodeNode = memo<Props>(function ({
525530 }
526531 } , [ data . parent , setPodParent , id ] ) ;
527532
533+ const onCopy = useCallback (
534+ ( clipboardData : any ) => {
535+ const pod = getPod ( id ) ;
536+ if ( ! pod ) return ;
537+ clipboardData . setData ( "text/plain" , pod . content ) ;
538+ clipboardData . setData (
539+ "application/json" ,
540+ JSON . stringify ( {
541+ type : "pod" ,
542+ data : pod ,
543+ } )
544+ ) ;
545+ } ,
546+ [ getPod , id ]
547+ ) ;
548+
528549 if ( ! pod ) return null ;
529550
530551 // onsize is banned for a guest, FIXME: ugly code
@@ -545,6 +566,7 @@ const CodeNode = memo<Props>(function ({
545566
546567 return Wrap (
547568 < Box
569+ id = { "reactflow_node_code_" + id }
548570 sx = { {
549571 border : "solid 1px #d6dee6" ,
550572 borderWidth : pod . ispublic ? "4px" : "2px" ,
@@ -630,6 +652,15 @@ const CodeNode = memo<Props>(function ({
630652 justifyContent : "center" ,
631653 } }
632654 className = "nodrag"
655+ onClick = { ( e ) => {
656+ const pane = document . getElementsByClassName (
657+ "react-flow__pane"
658+ ) [ 0 ] as HTMLElement ;
659+ if ( pane ) {
660+ pane . tabIndex = 0 ;
661+ pane . focus ( ) ;
662+ }
663+ } }
633664 >
634665 { role !== RoleType . GUEST && (
635666 < Tooltip title = "Run (shift-enter)" >
@@ -644,6 +675,16 @@ const CodeNode = memo<Props>(function ({
644675 </ IconButton >
645676 </ Tooltip >
646677 ) }
678+ < CopyToClipboard
679+ text = "dummy"
680+ options = { { debug : true , format : "text/plain" , onCopy } as any }
681+ >
682+ < Tooltip title = "Copy" >
683+ < IconButton size = "small" >
684+ < ContentCopyIcon fontSize = "inherit" />
685+ </ IconButton >
686+ </ Tooltip >
687+ </ CopyToClipboard >
647688 { role !== RoleType . GUEST && (
648689 < Tooltip title = "Delete" >
649690 < IconButton
@@ -730,6 +771,7 @@ function getAbsPos({ node, nodesMap }) {
730771export function Canvas ( ) {
731772 const [ nodes , setNodes , onNodesChange ] = useNodesStateSynced ( [ ] ) ;
732773 const [ edges , setEdges ] = useState < any [ ] > ( [ ] ) ;
774+ const [ pasting , setPasting ] = useState < null | string > ( null ) ;
733775
734776 const store = useContext ( RepoContext ) ;
735777 if ( ! store ) throw new Error ( "Missing BearContext.Provider in the tree" ) ;
@@ -794,9 +836,13 @@ export function Canvas() {
794836 }
795837 } ) ;
796838 setNodes (
797- Array . from ( nodesMap . values ( ) ) . sort (
798- ( a : Node & { level } , b : Node & { level } ) => a . level - b . level
799- )
839+ Array . from ( nodesMap . values ( ) )
840+ . filter (
841+ ( node ) =>
842+ ! node . data . hasOwnProperty ( "clientId" ) ||
843+ node . data . clientId === clientId
844+ )
845+ . sort ( ( a : Node & { level } , b : Node & { level } ) => a . level - b . level )
800846 ) ;
801847 } ;
802848
@@ -852,6 +898,10 @@ export function Canvas() {
852898 const setPodParent = useStore ( store , ( state ) => state . setPodParent ) ;
853899 const deletePod = useStore ( store , ( state ) => state . deletePod ) ;
854900 const userColor = useStore ( store , ( state ) => state . user ?. color ) ;
901+ const clientId = useStore (
902+ store ,
903+ ( state ) => state . provider ?. awareness ?. clientID
904+ ) ;
855905
856906 const addNode = useCallback (
857907 ( x : number , y : number , type : string ) => {
@@ -907,6 +957,7 @@ export function Canvas() {
907957 y : position . y ,
908958 width : style . width ,
909959 height : style . height ,
960+ dirty : true ,
910961 } ) ;
911962
912963 nodesMap . set ( id , newNode as any ) ;
@@ -945,9 +996,10 @@ export function Canvas() {
945996 // FIXME: add awareness info when dragging
946997 const onNodeDragStart = ( ) => { } ;
947998
948- const onNodeDragStop = useCallback (
949- // handle nodes list as multiple nodes can be dragged together at once
950- ( event , _n : Node , nodes : Node [ ] ) => {
999+ // Check if the nodes can be dropped into a scope when moving ends
1000+
1001+ const checkNodesEndLocation = useCallback (
1002+ ( event , nodes : Node [ ] , commonParent : string | undefined ) => {
9511003 const reactFlowBounds = reactFlowWrapper . current . getBoundingClientRect ( ) ;
9521004 // This mouse position is absolute within the canvas.
9531005 const mousePos = reactFlowInstance . project ( {
@@ -1058,7 +1110,6 @@ export function Canvas() {
10581110 } ) ;
10591111 } ) ;
10601112 } ,
1061- // We need to monitor nodes, so that getScopeAt can have all the nodes.
10621113 [
10631114 reactFlowInstance ,
10641115 getScopeAt ,
@@ -1069,6 +1120,14 @@ export function Canvas() {
10691120 ]
10701121 ) ;
10711122
1123+ const onNodeDragStop = useCallback (
1124+ // handle nodes list as multiple nodes can be dragged together at once
1125+ ( event , _n : Node , nodes : Node [ ] ) => {
1126+ checkNodesEndLocation ( event , nodes , commonParent ) ;
1127+ } ,
1128+ [ checkNodesEndLocation ]
1129+ ) ;
1130+
10721131 const onNodesDelete = useCallback (
10731132 ( nodes ) => {
10741133 // remove from pods
@@ -1090,17 +1149,199 @@ export function Canvas() {
10901149 const [ client , setClient ] = useState ( { x : 0 , y : 0 } ) ;
10911150
10921151 const onPaneContextMenu = ( event ) => {
1152+ console . log ( "onPaneContextMenu" , event ) ;
10931153 event . preventDefault ( ) ;
10941154 setShowContextMenu ( true ) ;
10951155 setPoints ( { x : event . pageX , y : event . pageY } ) ;
10961156 setClient ( { x : event . clientX , y : event . clientY } ) ;
1157+ console . log ( showContextMenu , points , client ) ;
10971158 } ;
10981159
1160+ const pasteCodePod = useCallback (
1161+ ( pod ) => {
1162+ const reactFlowBounds = reactFlowWrapper . current . getBoundingClientRect ( ) ;
1163+ let [ posX , posY ] = [
1164+ reactFlowBounds . width / 2 ,
1165+ reactFlowBounds . height / 2 ,
1166+ ] ;
1167+ const position = reactFlowInstance . project ( { x : posX , y : posY } ) ;
1168+ position . x = ( position . x - pod . width ! / 2 ) as number ;
1169+ position . y = ( position . y - ( pod . height ?? 0 ) / 2 ) as number ;
1170+
1171+ const style = {
1172+ width : pod . width ,
1173+ height : undefined ,
1174+ // create a temporary half-transparent pod
1175+ opacity : 0.5 ,
1176+ } ;
1177+
1178+ const id = nanoid ( ) ;
1179+ const newNode = {
1180+ id,
1181+ type : "code" ,
1182+ position,
1183+ data : {
1184+ name : pod ?. name || "" ,
1185+ label : id ,
1186+ parent : "ROOT" ,
1187+ clientId,
1188+ } ,
1189+ // the temporary pod should always be in the most front, set the level to a large number
1190+ level : 114514 ,
1191+ extent : "parent" ,
1192+ parentNode : undefined ,
1193+ dragHandle : ".custom-drag-handle" ,
1194+ style,
1195+ } ;
1196+
1197+ // create an informal (temporary) pod in local, without remote addPod
1198+ addPod ( null , {
1199+ id,
1200+ parent : "ROOT" ,
1201+ type : "CODE" ,
1202+ lang : "python" ,
1203+ x : position . x ,
1204+ y : position . y ,
1205+ width : pod . width ,
1206+ height : pod . height ,
1207+ content : pod . content ,
1208+ error : pod . error ,
1209+ stdout : pod . stdout ,
1210+ result : pod . result ,
1211+ name : pod . name ,
1212+ } ) ;
1213+
1214+ nodesMap . set ( id , newNode as any ) ;
1215+ setPasting ( id ) ;
1216+ } ,
1217+ [ addPod , clientId , nodesMap , reactFlowInstance , setPasting ]
1218+ ) ;
1219+
10991220 useEffect ( ( ) => {
1100- const handleClick = ( ) => setShowContextMenu ( false ) ;
1221+ const handleClick = ( e ) => {
1222+ setShowContextMenu ( false ) ;
1223+ } ;
1224+ const handlePaste = ( event ) => {
1225+ // avoid duplicated pastes
1226+ if ( pasting || role === RoleType . GUEST ) return ;
1227+
1228+ // only paste when the pane is focused
1229+ if (
1230+ event . target ?. className !== "react-flow__pane" &&
1231+ document . activeElement ?. className !== "react-flow__pane"
1232+ )
1233+ return ;
1234+
1235+ try {
1236+ // the user clipboard data is unpreditable, may have application/json from other source that can't be parsed by us, use try-catch here.
1237+ const playload = event . clipboardData . getData ( "application/json" ) ;
1238+ const data = JSON . parse ( playload ) ;
1239+ if ( data ?. type !== "pod" ) {
1240+ return ;
1241+ }
1242+ // clear the selection, make the temporary front-most
1243+ resetSelection ( ) ;
1244+ pasteCodePod ( data . data ) ;
1245+ // make the pane unreachable by keyboard (escape), or a black border shows up in the pane when pasting is canceled.
1246+ const pane = document . getElementsByClassName ( "react-flow__pane" ) [ 0 ] ;
1247+ if ( pane && pane . hasAttribute ( "tabindex" ) ) {
1248+ pane . removeAttribute ( "tabindex" ) ;
1249+ }
1250+ } catch ( e ) {
1251+ console . log ( "paste error" , e ) ;
1252+ }
1253+ } ;
11011254 document . addEventListener ( "click" , handleClick ) ;
1102- return ( ) => document . removeEventListener ( "click" , handleClick ) ;
1103- } , [ ] ) ;
1255+ document . addEventListener ( "paste" , handlePaste ) ;
1256+ return ( ) => {
1257+ document . removeEventListener ( "click" , handleClick ) ;
1258+ document . removeEventListener ( "paste" , handlePaste ) ;
1259+ } ;
1260+ } , [ pasteCodePod , pasting ] ) ;
1261+
1262+ useEffect ( ( ) => {
1263+ if ( ! pasting || ! reactFlowWrapper . current ) {
1264+ return ;
1265+ }
1266+
1267+ const mouseMove = ( event ) => {
1268+ const reactFlowBounds = reactFlowWrapper . current . getBoundingClientRect ( ) ;
1269+ const position = reactFlowInstance . project ( {
1270+ x : event . clientX - reactFlowBounds . left ,
1271+ y : event . clientY - reactFlowBounds . top ,
1272+ } ) ;
1273+ const node = nodesMap . get ( pasting ) ;
1274+ if ( ! node ) return ;
1275+ node . position = position ;
1276+ nodesMap . set ( pasting , node ) ;
1277+ } ;
1278+ const mouseClick = ( event ) => {
1279+ const node = nodesMap . get ( pasting ) ;
1280+ if ( ! node ) return ;
1281+ const newNode = {
1282+ ...node ,
1283+ level : 0 ,
1284+ style : {
1285+ width : node . style ?. width ,
1286+ height : node . style ?. height ,
1287+ } ,
1288+ data : {
1289+ name : node . data ?. name ,
1290+ label : node . data ?. label ,
1291+ parent : node . data ?. parent ,
1292+ } ,
1293+ } ;
1294+ const pod = getPod ( pasting ) ;
1295+ // delete the temporary node
1296+ nodesMap . delete ( pasting ) ;
1297+ // add the formal pod in place under root
1298+ addPod ( apolloClient , {
1299+ ...pod ,
1300+ } as any ) ;
1301+ nodesMap . set ( pasting , newNode ) ;
1302+
1303+ // check if the formal node is located in a scope, if it is, change its parent
1304+ const currentNode = reactFlowInstance . getNode ( pasting ) ;
1305+ checkNodesEndLocation ( event , [ currentNode ] , "ROOT" ) ;
1306+ //clear the pasting state
1307+ setPasting ( null ) ;
1308+ } ;
1309+ const keyDown = ( event ) => {
1310+ if ( event . key !== "Escape" ) return ;
1311+ // delete the temporary node
1312+ nodesMap . delete ( pasting ) ;
1313+ setPasting ( null ) ;
1314+ //clear the pasting state
1315+ event . preventDefault ( ) ;
1316+ } ;
1317+ reactFlowWrapper . current . addEventListener ( "mousemove" , mouseMove ) ;
1318+ reactFlowWrapper . current . addEventListener ( "click" , mouseClick ) ;
1319+ document . addEventListener ( "keydown" , keyDown ) ;
1320+ return ( ) => {
1321+ if ( reactFlowWrapper . current ) {
1322+ reactFlowWrapper . current . removeEventListener ( "mousemove" , mouseMove ) ;
1323+ reactFlowWrapper . current . removeEventListener ( "click" , mouseClick ) ;
1324+ }
1325+ document . removeEventListener ( "keydown" , keyDown ) ;
1326+ // FIXME(XINYI): auto focus on pane after finishing pasting should be set here, however, Escape triggers the tab selection on the element with tabindex=0, shows a black border on the pane. So I disable it.
1327+ } ;
1328+ } , [
1329+ pasting ,
1330+ reactFlowWrapper ,
1331+ setPasting ,
1332+ getPod ,
1333+ deletePod ,
1334+ addPod ,
1335+ apolloClient ,
1336+ reactFlowInstance ,
1337+ nodesMap ,
1338+ checkNodesEndLocation ,
1339+ ] ) ;
1340+
1341+ const onPaneClick = ( event ) => {
1342+ // focus
1343+ event . target . tabIndex = 0 ;
1344+ } ;
11041345
11051346 return (
11061347 < Box
@@ -1121,6 +1362,8 @@ export function Canvas() {
11211362 onNodeDragStart = { onNodeDragStart }
11221363 onNodeDragStop = { onNodeDragStop }
11231364 onNodesDelete = { onNodesDelete }
1365+ onPaneClick = { onPaneClick }
1366+ // onPaneMouseMove={onPaneMouseMove}
11241367 onSelectionChange = { onSelectionChange }
11251368 attributionPosition = "top-right"
11261369 maxZoom = { 10 }
0 commit comments