Skip to content

Commit efc7e7c

Browse files
authored
Feat: cross-page single-pod copy-paste in mouse-bound ctrl-v way (#158)
* draft: go back to clipboardData * fix some issues * fix content init * code clean up * fix: ban pasting from guests
1 parent 0f77926 commit efc7e7c

File tree

4 files changed

+321
-19
lines changed

4 files changed

+321
-19
lines changed

ui/src/components/Canvas.tsx

Lines changed: 255 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,12 @@ import Stack from "@mui/material/Stack";
3333
import Button from "@mui/material/Button";
3434
import CircleIcon from "@mui/icons-material/Circle";
3535
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
36+
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
3637
import Grid from "@mui/material/Grid";
3738
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline";
3839
import DeleteIcon from "@mui/icons-material/Delete";
3940
import ViewComfyIcon from "@mui/icons-material/ViewComfy";
40-
41+
import { CopyToClipboard } from "react-copy-to-clipboard";
4142
import Moveable from "react-moveable";
4243
import { ResizableBox } from "react-resizable";
4344
import Ansi from "ansi-to-react";
@@ -48,7 +49,11 @@ import { lowercase, numbers } from "nanoid-dictionary";
4849
import { useStore } from "zustand";
4950

5051
import { 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

5358
import { MyMonaco } from "./MyMonaco";
5459
import { 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 }) {
730771
export 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

Comments
 (0)