Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: cross-page single-pod copy-paste in mouse-bound ctrl-v way #158

Merged
merged 6 commits into from
Dec 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 255 additions & 12 deletions ui/src/components/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@ import Stack from "@mui/material/Stack";
import Button from "@mui/material/Button";
import CircleIcon from "@mui/icons-material/Circle";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import Grid from "@mui/material/Grid";
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline";
import DeleteIcon from "@mui/icons-material/Delete";
import ViewComfyIcon from "@mui/icons-material/ViewComfy";

import { CopyToClipboard } from "react-copy-to-clipboard";
import Moveable from "react-moveable";
import { ResizableBox } from "react-resizable";
import Ansi from "ansi-to-react";
Expand All @@ -48,7 +49,11 @@ import { lowercase, numbers } from "nanoid-dictionary";
import { useStore } from "zustand";

import { RepoContext, RoleType } from "../lib/store";
import { useNodesStateSynced, parent as commonParent } from "../lib/nodes";
import {
useNodesStateSynced,
resetSelection,
parent as commonParent,
} from "../lib/nodes";

import { MyMonaco } from "./MyMonaco";
import { useApolloClient } from "@apollo/client";
Expand Down Expand Up @@ -525,6 +530,22 @@ const CodeNode = memo<Props>(function ({
}
}, [data.parent, setPodParent, id]);

const onCopy = useCallback(
(clipboardData: any) => {
const pod = getPod(id);
if (!pod) return;
clipboardData.setData("text/plain", pod.content);
clipboardData.setData(
"application/json",
JSON.stringify({
type: "pod",
data: pod,
})
);
},
[getPod, id]
);

if (!pod) return null;

// onsize is banned for a guest, FIXME: ugly code
Expand All @@ -545,6 +566,7 @@ const CodeNode = memo<Props>(function ({

return Wrap(
<Box
id={"reactflow_node_code_" + id}
sx={{
border: "solid 1px #d6dee6",
borderWidth: pod.ispublic ? "4px" : "2px",
Expand Down Expand Up @@ -630,6 +652,15 @@ const CodeNode = memo<Props>(function ({
justifyContent: "center",
}}
className="nodrag"
onClick={(e) => {
const pane = document.getElementsByClassName(
"react-flow__pane"
)[0] as HTMLElement;
if (pane) {
pane.tabIndex = 0;
pane.focus();
}
}}
>
{role !== RoleType.GUEST && (
<Tooltip title="Run (shift-enter)">
Expand All @@ -644,6 +675,16 @@ const CodeNode = memo<Props>(function ({
</IconButton>
</Tooltip>
)}
<CopyToClipboard
text="dummy"
options={{ debug: true, format: "text/plain", onCopy } as any}
>
<Tooltip title="Copy">
<IconButton size="small">
<ContentCopyIcon fontSize="inherit" />
</IconButton>
</Tooltip>
</CopyToClipboard>
{role !== RoleType.GUEST && (
<Tooltip title="Delete">
<IconButton
Expand Down Expand Up @@ -730,6 +771,7 @@ function getAbsPos({ node, nodesMap }) {
export function Canvas() {
const [nodes, setNodes, onNodesChange] = useNodesStateSynced([]);
const [edges, setEdges] = useState<any[]>([]);
const [pasting, setPasting] = useState<null | string>(null);

const store = useContext(RepoContext);
if (!store) throw new Error("Missing BearContext.Provider in the tree");
Expand Down Expand Up @@ -794,9 +836,13 @@ export function Canvas() {
}
});
setNodes(
Array.from(nodesMap.values()).sort(
(a: Node & { level }, b: Node & { level }) => a.level - b.level
)
Array.from(nodesMap.values())
.filter(
(node) =>
!node.data.hasOwnProperty("clientId") ||
node.data.clientId === clientId
)
.sort((a: Node & { level }, b: Node & { level }) => a.level - b.level)
);
};

Expand Down Expand Up @@ -852,6 +898,10 @@ export function Canvas() {
const setPodParent = useStore(store, (state) => state.setPodParent);
const deletePod = useStore(store, (state) => state.deletePod);
const userColor = useStore(store, (state) => state.user?.color);
const clientId = useStore(
store,
(state) => state.provider?.awareness?.clientID
);

const addNode = useCallback(
(x: number, y: number, type: string) => {
Expand Down Expand Up @@ -907,6 +957,7 @@ export function Canvas() {
y: position.y,
width: style.width,
height: style.height,
dirty: true,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need dirty: true here? It will cause a updatePod request, which might reach server before addPod, causing errors.

Screenshot 2023-01-06 at 11 28 26 AM

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dirty parameter is removed in store.addPod but somehow remains here. It's a bug but shouldn't have any effect on addPod. Can you show me how to reproduce the error?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not reproduce on localhost. If you go to app.codepod.io, creating pods would sometime fail to sync.

But never mind, I've fixed it.

});

nodesMap.set(id, newNode as any);
Expand Down Expand Up @@ -945,9 +996,10 @@ export function Canvas() {
// FIXME: add awareness info when dragging
const onNodeDragStart = () => {};

const onNodeDragStop = useCallback(
// handle nodes list as multiple nodes can be dragged together at once
(event, _n: Node, nodes: Node[]) => {
// Check if the nodes can be dropped into a scope when moving ends

const checkNodesEndLocation = useCallback(
(event, nodes: Node[], commonParent: string | undefined) => {
const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect();
// This mouse position is absolute within the canvas.
const mousePos = reactFlowInstance.project({
Expand Down Expand Up @@ -1058,7 +1110,6 @@ export function Canvas() {
});
});
},
// We need to monitor nodes, so that getScopeAt can have all the nodes.
[
reactFlowInstance,
getScopeAt,
Expand All @@ -1069,6 +1120,14 @@ export function Canvas() {
]
);

const onNodeDragStop = useCallback(
// handle nodes list as multiple nodes can be dragged together at once
(event, _n: Node, nodes: Node[]) => {
checkNodesEndLocation(event, nodes, commonParent);
},
[checkNodesEndLocation]
);

const onNodesDelete = useCallback(
(nodes) => {
// remove from pods
Expand All @@ -1090,17 +1149,199 @@ export function Canvas() {
const [client, setClient] = useState({ x: 0, y: 0 });

const onPaneContextMenu = (event) => {
console.log("onPaneContextMenu", event);
event.preventDefault();
setShowContextMenu(true);
setPoints({ x: event.pageX, y: event.pageY });
setClient({ x: event.clientX, y: event.clientY });
console.log(showContextMenu, points, client);
};

const pasteCodePod = useCallback(
(pod) => {
const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect();
let [posX, posY] = [
reactFlowBounds.width / 2,
reactFlowBounds.height / 2,
];
const position = reactFlowInstance.project({ x: posX, y: posY });
position.x = (position.x - pod.width! / 2) as number;
position.y = (position.y - (pod.height ?? 0) / 2) as number;

const style = {
width: pod.width,
height: undefined,
// create a temporary half-transparent pod
opacity: 0.5,
};

const id = nanoid();
const newNode = {
id,
type: "code",
position,
data: {
name: pod?.name || "",
label: id,
parent: "ROOT",
clientId,
},
// the temporary pod should always be in the most front, set the level to a large number
level: 114514,
extent: "parent",
parentNode: undefined,
dragHandle: ".custom-drag-handle",
style,
};

// create an informal (temporary) pod in local, without remote addPod
addPod(null, {
id,
parent: "ROOT",
type: "CODE",
lang: "python",
x: position.x,
y: position.y,
width: pod.width,
height: pod.height,
content: pod.content,
error: pod.error,
stdout: pod.stdout,
result: pod.result,
name: pod.name,
});

nodesMap.set(id, newNode as any);
setPasting(id);
Comment on lines +1214 to +1215
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we set nodesMap, it would automatically sync with collaborators, right? UPDATE resolved; I saw that you checked clientID in various places in lib/nodes.tsx. That's a clever trick, looks good to me!

Copy link
Collaborator Author

@li-xin-yi li-xin-yi Dec 20, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is an adding event in nodesMap, usually, ymap.observe triggers a addPod call without remote DB writing, just adds it to the store.pods in each user's side. In this case, since I add the checking statement for clientId, collaborators will ignore it and won't call addPod until we delete the temporary floating transparent node and re-add the formal node.

},
[addPod, clientId, nodesMap, reactFlowInstance, setPasting]
);

useEffect(() => {
const handleClick = () => setShowContextMenu(false);
const handleClick = (e) => {
setShowContextMenu(false);
};
const handlePaste = (event) => {
// avoid duplicated pastes
if (pasting || role === RoleType.GUEST) return;

// only paste when the pane is focused
if (
event.target?.className !== "react-flow__pane" &&
document.activeElement?.className !== "react-flow__pane"
)
return;

try {
// the user clipboard data is unpreditable, may have application/json from other source that can't be parsed by us, use try-catch here.
const playload = event.clipboardData.getData("application/json");
const data = JSON.parse(playload);
if (data?.type !== "pod") {
return;
}
// clear the selection, make the temporary front-most
resetSelection();
pasteCodePod(data.data);
// make the pane unreachable by keyboard (escape), or a black border shows up in the pane when pasting is canceled.
const pane = document.getElementsByClassName("react-flow__pane")[0];
if (pane && pane.hasAttribute("tabindex")) {
pane.removeAttribute("tabindex");
}
} catch (e) {
console.log("paste error", e);
}
};
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
document.addEventListener("paste", handlePaste);
return () => {
document.removeEventListener("click", handleClick);
document.removeEventListener("paste", handlePaste);
};
}, [pasteCodePod, pasting]);

useEffect(() => {
if (!pasting || !reactFlowWrapper.current) {
return;
}

const mouseMove = (event) => {
const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect();
const position = reactFlowInstance.project({
x: event.clientX - reactFlowBounds.left,
y: event.clientY - reactFlowBounds.top,
});
const node = nodesMap.get(pasting);
if (!node) return;
node.position = position;
nodesMap.set(pasting, node);
};
const mouseClick = (event) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be helpful if the user could cancel the pasting before the drop by pressing Esc.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, sounds great, I will work on this feature later.

const node = nodesMap.get(pasting);
if (!node) return;
const newNode = {
...node,
level: 0,
style: {
width: node.style?.width,
height: node.style?.height,
},
data: {
name: node.data?.name,
label: node.data?.label,
parent: node.data?.parent,
},
};
const pod = getPod(pasting);
// delete the temporary node
nodesMap.delete(pasting);
// add the formal pod in place under root
addPod(apolloClient, {
...pod,
} as any);
nodesMap.set(pasting, newNode);

// check if the formal node is located in a scope, if it is, change its parent
const currentNode = reactFlowInstance.getNode(pasting);
checkNodesEndLocation(event, [currentNode], "ROOT");
//clear the pasting state
setPasting(null);
};
const keyDown = (event) => {
if (event.key !== "Escape") return;
// delete the temporary node
nodesMap.delete(pasting);
setPasting(null);
//clear the pasting state
event.preventDefault();
};
reactFlowWrapper.current.addEventListener("mousemove", mouseMove);
reactFlowWrapper.current.addEventListener("click", mouseClick);
document.addEventListener("keydown", keyDown);
return () => {
if (reactFlowWrapper.current) {
reactFlowWrapper.current.removeEventListener("mousemove", mouseMove);
reactFlowWrapper.current.removeEventListener("click", mouseClick);
}
document.removeEventListener("keydown", keyDown);
// 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.
};
}, [
pasting,
reactFlowWrapper,
setPasting,
getPod,
deletePod,
addPod,
apolloClient,
reactFlowInstance,
nodesMap,
checkNodesEndLocation,
]);

const onPaneClick = (event) => {
// focus
event.target.tabIndex = 0;
};

return (
<Box
Expand All @@ -1121,6 +1362,8 @@ export function Canvas() {
onNodeDragStart={onNodeDragStart}
onNodeDragStop={onNodeDragStop}
onNodesDelete={onNodesDelete}
onPaneClick={onPaneClick}
// onPaneMouseMove={onPaneMouseMove}
onSelectionChange={onSelectionChange}
attributionPosition="top-right"
maxZoom={10}
Expand Down
Loading