-
Notifications
You must be signed in to change notification settings - Fork 15
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
Changes from all commits
a22c41b
dde30f5
47f2c4c
357d128
2385b9f
b8e4f75
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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"; | ||
|
@@ -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"; | ||
|
@@ -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 | ||
|
@@ -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", | ||
|
@@ -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)"> | ||
|
@@ -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 | ||
|
@@ -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"); | ||
|
@@ -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) | ||
); | ||
}; | ||
|
||
|
@@ -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) => { | ||
|
@@ -907,6 +957,7 @@ export function Canvas() { | |
y: position.y, | ||
width: style.width, | ||
height: style.height, | ||
dirty: true, | ||
}); | ||
|
||
nodesMap.set(id, newNode as any); | ||
|
@@ -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({ | ||
|
@@ -1058,7 +1110,6 @@ export function Canvas() { | |
}); | ||
}); | ||
}, | ||
// We need to monitor nodes, so that getScopeAt can have all the nodes. | ||
[ | ||
reactFlowInstance, | ||
getScopeAt, | ||
|
@@ -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 | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it is an adding event in |
||
}, | ||
[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) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -1121,6 +1362,8 @@ export function Canvas() { | |
onNodeDragStart={onNodeDragStart} | ||
onNodeDragStop={onNodeDragStop} | ||
onNodesDelete={onNodesDelete} | ||
onPaneClick={onPaneClick} | ||
// onPaneMouseMove={onPaneMouseMove} | ||
onSelectionChange={onSelectionChange} | ||
attributionPosition="top-right" | ||
maxZoom={10} | ||
|
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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 instore.addPod
but somehow remains here. It's a bug but shouldn't have any effect onaddPod
. Can you show me how to reproduce the error?There was a problem hiding this comment.
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.