diff --git a/ui/src/components/Canvas.tsx b/ui/src/components/Canvas.tsx index b5c92b7a..16165dcb 100644 --- a/ui/src/components/Canvas.tsx +++ b/ui/src/components/Canvas.tsx @@ -20,29 +20,11 @@ import ReactFlow, { ConnectionMode, MarkerType, Node, + ReactFlowProvider, } from "reactflow"; import "reactflow/dist/style.css"; import Box from "@mui/material/Box"; -import InputBase from "@mui/material/InputBase"; -import CircularProgress from "@mui/material/CircularProgress"; -import Tooltip from "@mui/material/Tooltip"; -import IconButton from "@mui/material/IconButton"; -import PlayArrowIcon from "@mui/icons-material/PlayArrow"; -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 ContentCutIcon from "@mui/icons-material/ContentCut"; -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"; import { customAlphabet } from "nanoid"; import { lowercase, numbers } from "nanoid-dictionary"; @@ -56,713 +38,16 @@ import { parent as commonParent, } from "../lib/nodes"; -import { MyMonaco } from "./MyMonaco"; import { useApolloClient } from "@apollo/client"; import { CanvasContextMenu } from "./CanvasContextMenu"; -import styles from "./canvas.style.js"; import { ShareProjDialog } from "./ShareProjDialog"; -import { RichNode } from "./RichNode"; +import { RichNode } from "./nodes/Rich"; +import { CodeNode } from "./nodes/Code"; +import { ScopeNode } from "./nodes/Scope"; +import { YMap } from "yjs/dist/src/types/YMap"; const nanoid = customAlphabet(lowercase + numbers, 20); -interface Props { - data: any; - id: string; - isConnectable: boolean; - selected: boolean; - // note that xPos and yPos are the absolute position of the node - xPos: number; - yPos: number; -} - -const ScopeNode = memo(function ScopeNode({ - data, - id, - isConnectable, - selected, - xPos, - yPos, -}) { - // add resize to the node - const ref = useRef(null); - const store = useContext(RepoContext); - if (!store) throw new Error("Missing BearContext.Provider in the tree"); - const flow = useReactFlow(); - const setPodName = useStore(store, (state) => state.setPodName); - const updatePod = useStore(store, (state) => state.updatePod); - const setPodPosition = useStore(store, (state) => state.setPodPosition); - const setPodParent = useStore(store, (state) => state.setPodParent); - const [target, setTarget] = React.useState(); - const nodesMap = useStore(store, (state) => state.ydoc.getMap("pods")); - const [frame] = React.useState({ - translate: [0, 0], - }); - // const selected = useStore(store, (state) => state.pods[id]?.selected); - const role = useStore(store, (state) => state.role); - const inputRef = useRef(null); - - const deleteNodeById = useCallback( - (id: string) => { - flow.deleteElements({ - nodes: [ - { - id, - }, - ], - }); - }, - [flow] - ); - - const onResize = useCallback(({ width, height, offx, offy }) => { - const node = nodesMap.get(id); - if (node) { - node.style = { ...node.style, width, height }; - node.position.x += offx; - node.position.y += offy; - nodesMap.set(id, node); - } - }, []); - - useEffect(() => { - setTarget(ref.current); - }, []); - - useEffect(() => { - if (!data.name) return; - setPodName({ id, name: data.name || "" }); - if (inputRef?.current) { - inputRef.current.value = data.name; - } - }, [data.name, id, setPodName]); - - useEffect(() => { - // get relative position - const node = nodesMap.get(id); - if (node?.position) { - // update pods[id].position but don't trigger DB update (dirty: false) - setPodPosition({ - id, - x: node.position.x, - y: node.position.y, - dirty: false, - }); - } - }, [xPos, yPos, setPodPosition, id]); - - useEffect(() => { - if (data.parent && data.parent !== "ROOT") { - setPodParent({ id, parent: data.parent, dirty: false }); - } - }, [data.parent, setPodParent, id]); - - return ( - - - {role !== RoleType.GUEST && ( - - { - e.stopPropagation(); - e.preventDefault(); - deleteNodeById(id); - }} - > - - - - )} - - - {/* The header of scope nodes. */} - - - - {/* - - */} - - - - { - const name = e.target.value; - if (name === data.name) return; - const node = nodesMap.get(id); - if (node) { - nodesMap.set(id, { - ...node, - data: { ...node.data, name }, - }); - } - // setPodName({ id, name }); - }} - inputRef={inputRef} - disabled={role === RoleType.GUEST} - inputProps={{ - style: { - padding: "0px", - textAlign: "center", - textOverflow: "ellipsis", - }, - }} - > - - - - - - - - - {selected && role !== RoleType.GUEST && ( - { - e.setOrigin(["%", "%"]); - e.dragStart && e.dragStart.set(frame.translate); - }} - onResize={(e) => { - const beforeTranslate = e.drag.beforeTranslate; - frame.translate = beforeTranslate; - e.target.style.width = `${e.width}px`; - e.target.style.height = `${e.height}px`; - e.target.style.transform = `translate(${beforeTranslate[0]}px, ${beforeTranslate[1]}px)`; - onResize({ - width: e.width, - height: e.height, - offx: beforeTranslate[0], - offy: beforeTranslate[1], - }); - updatePod({ - id, - data: { - width: e.width, - height: e.height, - }, - }); - }} - /> - )} - - ); -}); - -export const ResultBlock = memo(function ResultBlock({ id }) { - const store = useContext(RepoContext)!; - const height = useStore(store, (state) => state.pods[id].height); - const result = useStore(store, (state) => state.pods[id].result); - const error = useStore(store, (state) => state.pods[id].error); - const stdout = useStore(store, (state) => state.pods[id].stdout); - const running = useStore(store, (state) => state.pods[id].running); - const [showOutput, setShowOutput] = useState(true); - return ( - - {result && ( - - {result.html ? ( -
- ) : ( - <> - {!error && ( - - - - - - )} - - )} - {result.image && ( - output - )} -
- )} - - {running && } - {showOutput ? ( - - {/* Error */} - - {stdout && ( - - {stdout} - - )} - {result?.text && result?.count > 0 && ( - - - {result.text} - - - )} - {error && {error?.evalue}} - {error?.stacktrace && ( - - StackTrace - - {error.stacktrace.join("\n")} - - - )} - - ) : ( - - This output has been hidden. - - - )} -
- ); -}); - -const CodeNode = memo(function ({ - data, - id, - isConnectable, - selected, - xPos, - yPos, -}) { - const store = useContext(RepoContext); - if (!store) throw new Error("Missing BearContext.Provider in the tree"); - // const pod = useStore(store, (state) => state.pods[id]); - const wsRun = useStore(store, (state) => state.wsRun); - const clearResults = useStore(store, (s) => s.clearResults); - const ref = useRef(null); - const [target, setTarget] = React.useState(null); - const [frame] = React.useState({ - translate: [0, 0], - }); - // right, bottom - const [layout, setLayout] = useState("bottom"); - const isRightLayout = layout === "right"; - const setPodName = useStore(store, (state) => state.setPodName); - const setPodPosition = useStore(store, (state) => state.setPodPosition); - const setCurrentEditor = useStore(store, (state) => state.setCurrentEditor); - const setPodParent = useStore(store, (state) => state.setPodParent); - const getPod = useStore(store, (state) => state.getPod); - const setCutting = useStore(store, (state) => state.setCutting); - const pod = getPod(id); - const role = useStore(store, (state) => state.role); - const width = useStore(store, (state) => state.pods[id]?.width); - const isPodFocused = useStore(store, (state) => state.pods[id]?.focus); - const index = useStore( - store, - (state) => state.pods[id]?.result?.count || " " - ); - const inputRef = useRef(null); - - const showResult = useStore( - store, - (state) => - state.pods[id]?.running || - state.pods[id]?.result || - state.pods[id]?.error || - state.pods[id]?.stdout || - state.pods[id]?.stderr - ); - const onResize = useCallback((e, data) => { - const { size } = data; - const node = nodesMap.get(id); - if (node) { - node.style = { ...node.style, width: size.width }; - nodesMap.set(id, node); - } - }, []); - const nodesMap = useStore(store, (state) => state.ydoc.getMap("pods")); - const apolloClient = useApolloClient(); - const deletePod = useStore(store, (state) => state.deletePod); - const deleteNodeById = (id) => { - deletePod(apolloClient, { id: id, toDelete: [] }); - nodesMap.delete(id); - }; - - useEffect(() => { - setTarget(ref.current); - }, []); - - useEffect(() => { - if (!data.name) return; - setPodName({ id, name: data.name }); - if (inputRef?.current) { - inputRef.current.value = data.name || ""; - } - }, [data.name, setPodName, id]); - - useEffect(() => { - // get relative position - const node = nodesMap.get(id); - if (node?.position) { - // update pods[id].position but don't trigger DB update (dirty: false) - setPodPosition({ - id, - x: node.position.x, - y: node.position.y, - dirty: false, - }); - } - }, [xPos, yPos, setPodPosition, id]); - - useEffect(() => { - if (data.parent !== undefined) { - setPodParent({ id, parent: data.parent, dirty: false }); - } - }, [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] - ); - - const onCut = useCallback( - (clipboardData: any) => { - onCopy(clipboardData); - setCutting(id); - }, - [onCopy, setCutting, id] - ); - - if (!pod) return null; - - // onsize is banned for a guest, FIXME: ugly code - const Wrap = (child) => - role === RoleType.GUEST ? ( - <>{child} - ) : ( - - {child} - - ); - - return Wrap( - - - - - - {/* The header of code pods. */} - - - { - const name = e.target.value; - if (name === data.name) return; - const node = nodesMap.get(id); - if (node) { - nodesMap.set(id, { ...node, data: { ...node.data, name } }); - } - }} - inputProps={{ - style: { - padding: "0px", - textOverflow: "ellipsis", - }, - }} - > - - [{index}] - { - const pane = document.getElementsByClassName( - "react-flow__pane" - )[0] as HTMLElement; - if (pane) { - pane.tabIndex = 0; - pane.focus(); - } - }} - > - {role !== RoleType.GUEST && ( - - { - clearResults(id); - wsRun(id); - }} - > - - - - )} - - - - - - - - - - - - - - - {role !== RoleType.GUEST && ( - - { - deleteNodeById(id); - }} - > - - - - )} - - { - setLayout(layout === "bottom" ? "right" : "bottom"); - }} - > - - - - - - - - {showResult && ( - - - - )} - - - ); -}); - const nodeTypes = { scope: ScopeNode, code: CodeNode, rich: RichNode }; const level2color = { @@ -823,225 +108,323 @@ function nodetype2dbtype(nodetype: string) { } } -export function Canvas() { - const [nodes, setNodes, onNodesChange] = useNodesStateSynced([]); - const [edges, setEdges] = useState([]); +/** + * This hook will load nodes from zustand store into Yjs nodesMap using setNodes. + * @returns None + */ +function store2nodes(id: string, level: number, { getId2children, getPod }) { + let res: any[] = []; + let children = getId2children(id) || []; + const pod = getPod(id); + if (id !== "ROOT") { + res.push({ + id: id, + type: dbtype2nodetype(pod.type), + data: { + // label: `ID: ${id}, parent: ${pods[id].parent}, pos: ${pods[id].x}, ${pods[id].y}`, + label: id, + name: pod.name, + parent: pod.parent, + level, + }, + // position: { x: 100, y: 100 }, + position: { x: pod.x, y: pod.y }, + parentNode: pod.parent !== "ROOT" ? pod.parent : undefined, + extent: "parent", + style: { + backgroundColor: + pod.type !== "DECK" + ? undefined + : level2color[level] || level2color["default"], + }, + width: pod.width || undefined, + // for code node, don't set height, let it be auto + height: pod.height || undefined, + dragHandle: ".custom-drag-handle", + }); + } + for (const child of children) { + res = res.concat(store2nodes(child, level + 1, { getId2children, getPod })); + } + return res; +} + +/** + * Copy and paste utility functions. + * @param reactFlowWrapper + * @returns + */ +function useCopyPaste(reactFlowWrapper) { const [pasting, setPasting] = useState(null); const store = useContext(RepoContext); if (!store) throw new Error("Missing BearContext.Provider in the tree"); - // the real pods - const getId2children = useStore(store, (state) => state.getId2children); - // const pods = useStore(store, (state) => state.pods); + const getPod = useStore(store, (state) => state.getPod); const nodesMap = useStore(store, (state) => state.ydoc.getMap("pods")); - const repoId = useStore(store, (state) => state.repoId); const role = useStore(store, (state) => state.role); - const provider = useStore(store, (state) => state.provider); - const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; - const shareOpen = useStore(store, (state) => state.shareOpen); - const setShareOpen = useStore(store, (state) => state.setShareOpen); - const cutting = useStore(store, (state) => state.cutting); - const setCutting = useStore(store, (state) => state.setCutting); - - const getRealNodes = useCallback( - (id: string, level: number) => { - let res: any[] = []; - let children = getId2children(id) || []; - const pod = getPod(id); - if (id !== "ROOT") { - res.push({ - id: id, - type: dbtype2nodetype(pod.type), - data: { - // label: `ID: ${id}, parent: ${pods[id].parent}, pos: ${pods[id].x}, ${pods[id].y}`, - label: id, - name: pod.name, - parent: pod.parent, - }, - // position: { x: 100, y: 100 }, - position: { x: pod.x, y: pod.y }, - parentNode: pod.parent !== "ROOT" ? pod.parent : undefined, - extent: "parent", - level, - style: { - backgroundColor: - pod.type !== "DECK" - ? undefined - : level2color[level] || level2color["default"], - width: pod.width || undefined, - // for code node, don't set height, let it be auto - height: pod.height || undefined, - }, - dragHandle: ".custom-drag-handle", - }); - } - for (const child of children) { - res = res.concat(getRealNodes(child, level + 1)); - } - return res; - }, - [getId2children, getPod] - ); - useEffect(() => { - const init = () => { - let nodes = getRealNodes("ROOT", -1); - nodes.forEach((node) => { - if (!nodesMap.has(node.id)) { - console.log("add node", node.id, node); - nodesMap.set(node.id, node); - } - }); - setNodes( - 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) - ); - }; - - if (!provider) return; - if (provider.synced) { - init(); - } else { - provider.once("synced", init); - } - - // cancel in-progress pasting when exiting the canvas - return cancelPaste; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [provider]); - - // const onNodesChange = useCallback( - // (changes) => { - // setNodes((nds) => applyNodeChanges(changes, nds)); - // }, - // [setNodes] - // ); - - const onEdgesChange = useCallback( - (changes) => setEdges((eds) => applyEdgeChanges(changes, eds)), - [setEdges] - ); - const onConnect = useCallback( - (connection) => - setEdges((eds) => - addEdge( - { - ...connection, - markerEnd: { - type: MarkerType.ArrowClosed, - color: "black", - }, - style: { - stroke: "black", - strokeWidth: 3, - }, - }, - eds - ) - ), - [setEdges] - ); - - const [reactFlowInstance, setReactFlowInstance] = useState(null); - const reactFlowWrapper = useRef(null); + const reactFlowInstance = useReactFlow(); + const cutting = useStore(store, (state) => state.cutting); + const setCutting = useStore(store, (state) => state.setCutting); const addPod = useStore(store, (state) => state.addPod); const apolloClient = useApolloClient(); - const setPodPosition = useStore(store, (state) => state.setPodPosition); - 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 { checkNodesEndLocation } = useNodeLocation(reactFlowWrapper); - const addNode = useCallback( - (x: number, y: number, type: "code" | "scope" | "rich") => { - const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect(); - let style; - - switch (type) { - case "scope": - style = { - backgroundColor: level2color[0], - width: 600, - height: 600, - }; - break; - case "code": - case "rich": - style = { - width: 300, - // we must not set the height here, otherwise the auto layout will not work - height: undefined, - }; - break; - default: - throw new Error(`unknown type ${type}`); + const cancelPaste = useCallback(() => { + if (!pasting) return; + nodesMap.delete(pasting); + setPasting(null); + if (cutting) { + // recover the hideen original node + const node = nodesMap.get(cutting); + if (node?.data?.hidden) { + delete node.data.hidden; + nodesMap.set(cutting, node); } + setCutting(null); + } + }, [cutting, nodesMap, pasting, setCutting]); + useEffect(() => { + if (!pasting || !reactFlowWrapper.current) { + return; + } + + const mouseMove = (event) => { + const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect(); const position = reactFlowInstance.project({ - x: x - reactFlowBounds.left, - y: y - reactFlowBounds.top, + x: event.clientX - reactFlowBounds.left, + y: event.clientY - reactFlowBounds.top, }); - let id = nanoid(); + const node = nodesMap.get(pasting); + if (!node) return; + node.position = position; + nodesMap.set(pasting, node); + }; + const mouseClick = (event) => { + const node = nodesMap.get(pasting); + if (!node) return; + const newNode = { + ...node, + width: node.width, + height: node.height, + style: { ...node.style, opacity: 1 }, + data: { + level: 0, + 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); + 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); + if (currentNode) { + checkNodesEndLocation(event, [currentNode], "ROOT"); + } + //clear the pasting state + setPasting(null); + // delete the original (hidden) node + if (cutting) { + nodesMap.delete(cutting); + setCutting(null); + } + }; + const keyDown = (event) => { + if (event.key !== "Escape") return; + // delete the temporary node + cancelPaste(); + //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. + }; + }, [ + addPod, + apolloClient, + cancelPaste, + checkNodesEndLocation, + cutting, + getPod, + nodesMap, + pasting, + reactFlowInstance, + reactFlowWrapper, + setCutting, + ]); + + const createTemprorayNode = useCallback( + (pod, position) => { + const id = nanoid(); const newNode = { id, - type, + type: "code", position, - style, data: { + name: pod?.name || "", label: id, - name: "", parent: "ROOT", + clientId, + // the temporary pod should always be in the most front, set the level to a large number + level: 114514, }, - level: 0, - extent: "parent", - //otherwise, throws a lot of warnings, see https://reactflow.dev/docs/guides/troubleshooting/#only-child-nodes-can-use-a-parent-extent + extent: "parent" as "parent", parentNode: undefined, dragHandle: ".custom-drag-handle", + width: pod.width, + style: { + // create a temporary half-transparent pod + opacity: 0.5, + }, }; - // setNodes((nds) => nds.concat(newNode)); - - // add to pods - addPod(apolloClient, { + // create an informal (temporary) pod in local, without remote addPod + addPod(null, { id, parent: "ROOT", - type: nodetype2dbtype(type), + type: "CODE", children: [], lang: "python", x: position.x, y: position.y, - width: style.width, - height: style.height, - dirty: true, + 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); + nodesMap.set(id, newNode); + setPasting(id); + + // 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"); + } }, + [addPod, clientId, nodesMap, setPasting] + ); + + 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; + + createTemprorayNode(pod, position); + }, + [createTemprorayNode, reactFlowInstance, reactFlowWrapper] + ); + + useEffect(() => { + if (cutting) { + // when a pod is being cut, generate a new temporary node and hide the + // original node + const node = nodesMap.get(cutting); + if (!node) return; + const position = node.positionAbsolute ?? node.position; + createTemprorayNode(getPod(cutting), position); + node.data.hidden = clientId; + nodesMap.set(cutting, node); + } + }, [clientId, createTemprorayNode, cutting, getPod, nodesMap]); + + const handlePaste = useCallback( + (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; - [addPod, apolloClient, nodesMap, reactFlowInstance] + 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); + } catch (e) { + console.log("paste error", e); + } + }, + [pasteCodePod, pasting, role] ); + useEffect(() => { + document.addEventListener("paste", handlePaste); + return () => { + document.removeEventListener("paste", handlePaste); + }; + }, [handlePaste]); +} + +/** + * Helper functions to manipulate node locations. + * @returns {checkNodesEndLocation} + */ +const useNodeLocation = (reactFlowWrapper) => { + const store = useContext(RepoContext)!; + const nodesMap = useStore(store, (state) => state.ydoc.getMap("pods")); + const nodes = useReactFlow().getNodes(); + const reactFlowInstance = useReactFlow(); + const getPod = useStore(store, (state) => state.getPod); + + /** + * Check bounding boxes of all scopes. + */ const getScopeAt = useCallback( (x: number, y: number, ids: string[]) => { - const scope = nodes.findLast((node) => { + const scope = nodes.reverse().find((node) => { let [x1, y1] = getAbsPos({ node, nodesMap }); return ( node.type === "scope" && x >= x1 && !ids.includes(node.id) && - x <= x1 + node.style.width && + x <= x1 + node.width && y >= y1 && - y <= y1 + node.style.height + y <= y1 + node.height ); }); return scope; @@ -1057,12 +440,6 @@ export function Canvas() { * 1. Update the position of the node in the redux store. * 2. Check if the node is moved into a scope. If so, update the parent of the node. */ - - // FIXME: add awareness info when dragging - const onNodeDragStart = () => {}; - - // 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(); @@ -1094,14 +471,6 @@ export function Canvas() { // the mouse is outside the current parent, the nodes can't be dragged out // console.log("Cannot drop outside parent scope"); // but position should also be updated - nodes.forEach((node) => { - setPodPosition({ - id: node.id, - x: node.position.x, - y: node.position.y, - dirty: true, - }); - }); return; } } @@ -1110,14 +479,6 @@ export function Canvas() { // no target scope, or the target scope is the same as the current parent if (!scope || scope.id === commonParent) { // only update position and exit, avoid updating parentNode - nodes.forEach((node) => { - setPodPosition({ - id: node.id, - x: node.position.x, - y: node.position.y, - dirty: true, - }); - }); return; } @@ -1125,7 +486,7 @@ export function Canvas() { function updateLevel(id: string, level: number) { const node = nodesMap.get(id); if (node) { - (node as any).level = level; + node.data.level = level; node.style!.backgroundColor = level2color[level]; nodesMap.set(id, node); getPod(id)?.children.forEach(({ id }) => updateLevel(id, level + 1)); @@ -1145,46 +506,267 @@ export function Canvas() { // auto-align the node to, keep it bound in the scope // FIXME: it assumes the scope must be larger than the node - absX = Math.max(absX, 0); - absX = Math.min(absX, scope.width! - node.width!); - absY = Math.max(absY, 0); - absY = Math.min(absY, scope.height! - node.height!); + absX = Math.max(absX, 0); + absX = Math.min(absX, scope.width! - node.width!); + absY = Math.max(absY, 0); + absY = Math.min(absY, scope.height! - node.height!); + + const currentNode = nodesMap.get(node.id); + if (currentNode) { + currentNode.parentNode = scope.id; + currentNode.data!.parent = scope.id; + currentNode.position = { x: absX, y: absY }; + nodesMap.set(node.id, currentNode); + } + + updateLevel(node.id, scope.data.level + 1); + }); + }, + [reactFlowWrapper, reactFlowInstance, getScopeAt, nodesMap, getPod] + ); + return { checkNodesEndLocation }; +}; + +const useNodeOperations = (reactFlowWrapper) => { + const store = useContext(RepoContext)!; + const nodesMap = useStore(store, (state) => state.ydoc.getMap("pods")); + const reactFlowInstance = useReactFlow(); + const addPod = useStore(store, (state) => state.addPod); + const apolloClient = useApolloClient(); + const addNode = useCallback( + (x: number, y: number, type: "code" | "scope" | "rich") => { + const reactFlowBounds = reactFlowWrapper.current.getBoundingClientRect(); + let style = {}; + let width; + let height; + + switch (type) { + case "scope": + style = { backgroundColor: level2color[0] }; + width = 600; + height = 600; + break; + case "code": + case "rich": + width = 300; + // we must not set the height here, otherwise the auto layout will not work + break; + default: + throw new Error(`unknown type ${type}`); + } + + const position = reactFlowInstance.project({ + x: x - reactFlowBounds.left, + y: y - reactFlowBounds.top, + }); + let id = nanoid(); + const newNode = { + id, + type, + position, + width, + height, + // IMPORTANT: the width and height must be set here, otherwise the auto + // layout will not work. + style: { ...style, width, height }, + data: { + label: id, + name: "", + parent: "ROOT", + level: 0, + }, + extent: "parent" as "parent", + //otherwise, throws a lot of warnings, see + //https://reactflow.dev/docs/guides/troubleshooting/#only-child-nodes-can-use-a-parent-extent + parentNode: undefined, + dragHandle: ".custom-drag-handle", + }; + + // setNodes((nds) => nds.concat(newNode)); + + // add to pods + addPod(apolloClient, { + id, + parent: "ROOT", + type: nodetype2dbtype(type), + children: [], + lang: "python", + x: position.x, + y: position.y, + width, + height, + dirty: true, + }); + + nodesMap.set(id, newNode); + }, + + [addPod, apolloClient, nodesMap, reactFlowInstance, reactFlowWrapper] + ); + return { addNode }; +}; + +function verifyConsistency(nodes: Node[], nodesMap: YMap) { + let keys = new Set(nodesMap.keys()); + let nodesMap2 = new Map(); + nodes.forEach((node) => nodesMap2.set(node.id, node)); + let keys2 = new Set(nodesMap2.keys()); + if (keys.size !== keys2.size) { + console.error("keys are not the same", keys, keys2); + return false; + } + for (let i = 0; i < keys.size; i++) { + if (keys[i] !== keys2[i]) { + console.error("keys are not the same", keys, keys2); + return false; + } + } + // verify the values + keys.forEach((key) => { + let node1 = nodesMap.get(key); + let node2 = nodesMap2.get(key); + if (!node1) { + console.error("node1 is undefined"); + return false; + } + if (!node2) { + console.error("node2 is undefined"); + return false; + } + if (node1.id !== node2.id) { + console.error("node id are not the same", node1.id, node2.id); + return false; + } + if (node1.parentNode !== node2.parentNode) { + console.error( + "node parent are not the same", + node1.parentNode, + node2.parentNode + ); + return false; + } + if (node1.position.x !== node2.position.x) { + console.error( + "node x are not the same", + node1.position.x, + node2.position.x + ); + return false; + } + if (node1.position.y !== node2.position.y) { + console.error( + "node y are not the same", + node1.position.y, + node2.position.y + ); + return false; + } + }); + return true; +} + +function useInitNodes({ triggerUpdate }) { + const store = useContext(RepoContext)!; + const getPod = useStore(store, (state) => state.getPod); + const nodesMap = useStore(store, (state) => state.ydoc.getMap("pods")); + const getId2children = useStore(store, (state) => state.getId2children); + const provider = useStore(store, (state) => state.provider); + const [loading, setLoading] = useState(true); + useEffect(() => { + const init = () => { + let nodes = store2nodes("ROOT", -1, { getId2children, getPod }); + // Verify that the nodes are the same as the remote database + if (nodesMap.size !== nodes.length) { + console.info( + "The yjs server is empty but database is not. Initializing the yjs server." + ); + nodes.forEach((node) => { + if (!nodesMap.has(node.id)) { + nodesMap.set(node.id, node); + } + }); + } + let isConsistent = verifyConsistency(nodes, nodesMap); + if (!isConsistent) { + console.warn( + "The yjs server is not consistent with the database. Resetting the yjs server" + ); + // throw new Error("Inconsistent state"); + nodes.forEach((node) => { + if (!nodesMap.has(node.id)) { + nodesMap.set(node.id, node); + } + }); + } + // NOTE we have to trigger an update here, otherwise the nodes are not + // rendered. + triggerUpdate(); + setLoading(false); + }; + + if (!provider) return; + if (provider.synced) { + init(); + } else { + provider.once("synced", init); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [provider]); + return { loading }; +} + +/** + * The canvas. + * @returns + */ +function CanvasImpl() { + const { nodes, onNodesChange, triggerUpdate } = useNodesStateSynced(); + const [edges, setEdges] = useState([]); - setPodParent({ - id: node.id, - parent: scope.id, - dirty: true, - }); + const reactFlowWrapper = useRef(null); + const { loading } = useInitNodes({ triggerUpdate }); + useCopyPaste(reactFlowWrapper); - const currentNode = nodesMap.get(node.id); - if (currentNode) { - currentNode.parentNode = scope.id; - currentNode.data!.parent = scope.id; - currentNode.position = { x: absX, y: absY }; - nodesMap.set(node.id, currentNode); - } + const store = useContext(RepoContext); + if (!store) throw new Error("Missing BearContext.Provider in the tree"); + const repoId = useStore(store, (state) => state.repoId); + const role = useStore(store, (state) => state.role); - updateLevel(node.id, scope.level + 1); + const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; + const shareOpen = useStore(store, (state) => state.shareOpen); + const setShareOpen = useStore(store, (state) => state.setShareOpen); - // update - setPodPosition({ - id: node.id, - x: absX, - y: absY, - dirty: true, - }); - }); - }, - [ - reactFlowInstance, - getScopeAt, - setPodPosition, - nodesMap, - setPodParent, - getPod, - ] + const { checkNodesEndLocation } = useNodeLocation(reactFlowWrapper); + const { addNode } = useNodeOperations(reactFlowWrapper); + + const onEdgesChange = useCallback( + (changes) => setEdges((eds) => applyEdgeChanges(changes, eds)), + [setEdges] + ); + const onConnect = useCallback( + (connection) => + setEdges((eds) => + addEdge( + { + ...connection, + markerEnd: { + type: MarkerType.ArrowClosed, + color: "black", + }, + style: { + stroke: "black", + strokeWidth: 3, + }, + }, + eds + ) + ), + [setEdges] ); + // Check if the nodes can be dropped into a scope when moving ends + // const checkNodesEndLocation = useCallback + const onNodeDragStop = useCallback( // handle nodes list as multiple nodes can be dragged together at once (event, _n: Node, nodes: Node[]) => { @@ -1193,22 +775,6 @@ export function Canvas() { [checkNodesEndLocation] ); - const onNodesDelete = useCallback( - (nodes) => { - // remove from pods - for (const node of nodes) { - deletePod(apolloClient, { id: node.id, toDelete: [] }); - } - }, - [apolloClient, deletePod] - ); - - const onSelectionChange = useCallback(({ nodes, edges }) => { - // just for debug - // console.log("selection changed", nodes, edges); - // setSelection({nodes, edges}); - }, []); - const [showContextMenu, setShowContextMenu] = useState(false); const [points, setPoints] = useState({ x: 0, y: 0 }); const [client, setClient] = useState({ x: 0, y: 0 }); @@ -1222,234 +788,23 @@ export function Canvas() { console.log(showContextMenu, points, client); }; - const createTemprorayNode = useCallback( - (pod, position) => { - 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", - children: [], - 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); - - // 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"); - } - }, - [addPod, clientId, nodesMap, setPasting] - ); - - 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; - - createTemprorayNode(pod, position); - }, - [createTemprorayNode, reactFlowInstance] - ); - useEffect(() => { 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); - } catch (e) { - console.log("paste error", e); - } - }; document.addEventListener("click", handleClick); - document.addEventListener("paste", handlePaste); return () => { document.removeEventListener("click", handleClick); - document.removeEventListener("paste", handlePaste); - }; - }, [pasteCodePod, setShowContextMenu, pasting, role, resetSelection]); - - const cancelPaste = useCallback(() => { - if (!pasting) return; - nodesMap.delete(pasting); - setPasting(null); - if (cutting) { - // recover the hideen original node - const node = nodesMap.get(cutting); - if (node?.data?.hidden) { - delete node.data.hidden; - nodesMap.set(cutting, node); - } - setCutting(null); - } - }, [cutting, nodesMap, 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) => { - 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); - // delete the original (hidden) node - if (cutting) { - reactFlowInstance.deleteElements({ nodes: [{ id: cutting }] }); - setCutting(null); - } - }; - const keyDown = (event) => { - if (event.key !== "Escape") return; - // delete the temporary node - cancelPaste(); - //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, - cancelPaste, - ]); - - useEffect(() => { - if (cutting) { - // when a pod is being cut, generate a new temporary node and hide the original node - const node = nodesMap.get(cutting); - if (!node) return; - const position = node.positionAbsolute ?? node.position; - createTemprorayNode(getPod(cutting), position); - node.data.hidden = clientId; - nodesMap.set(cutting, node); - } - }, [cutting]); + }, [setShowContextMenu, role]); const onPaneClick = (event) => { // focus event.target.tabIndex = 0; }; + if (loading) return
Loading...
; + return ( ); } + +export function Canvas() { + return ( + + + + ); +} diff --git a/ui/src/components/nodes/Code.tsx b/ui/src/components/nodes/Code.tsx new file mode 100644 index 00000000..765851ec --- /dev/null +++ b/ui/src/components/nodes/Code.tsx @@ -0,0 +1,518 @@ +import { + useCallback, + useState, + useRef, + useContext, + useEffect, + memo, +} from "react"; +import * as React from "react"; +import ReactFlow, { + addEdge, + applyEdgeChanges, + applyNodeChanges, + Background, + MiniMap, + Controls, + Handle, + useReactFlow, + Position, + ConnectionMode, + MarkerType, + Node, +} from "reactflow"; +import "reactflow/dist/style.css"; + +import Box from "@mui/material/Box"; +import InputBase from "@mui/material/InputBase"; +import CircularProgress from "@mui/material/CircularProgress"; +import Tooltip from "@mui/material/Tooltip"; +import IconButton from "@mui/material/IconButton"; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +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 ContentCutIcon from "@mui/icons-material/ContentCut"; +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"; + +import { useStore } from "zustand"; + +import { RepoContext, RoleType } from "../../lib/store"; + +import { MyMonaco } from "../MyMonaco"; +import { useApolloClient } from "@apollo/client"; + +interface Props { + data: any; + id: string; + isConnectable: boolean; + selected: boolean; + // note that xPos and yPos are the absolute position of the node + xPos: number; + yPos: number; +} + +export const ResultBlock = memo(function ResultBlock({ id }) { + const store = useContext(RepoContext)!; + const result = useStore(store, (state) => state.pods[id].result); + const error = useStore(store, (state) => state.pods[id].error); + const stdout = useStore(store, (state) => state.pods[id].stdout); + const running = useStore(store, (state) => state.pods[id].running); + const [showOutput, setShowOutput] = useState(true); + return ( + + {result && ( + + {result.html ? ( +
+ ) : ( + <> + {!error && ( + + + + + + )} + + )} + {result.image && ( + output + )} +
+ )} + + {running && } + {showOutput ? ( + + {/* Error */} + + {stdout && ( + + {stdout} + + )} + {result?.text && result?.count > 0 && ( + + + {result.text} + + + )} + {error && {error?.evalue}} + {error?.stacktrace && ( + + StackTrace + + {error.stacktrace.join("\n")} + + + )} + + ) : ( + + + This output has been hidden.{" "} + + + + )} +
+ ); +}); + +export const CodeNode = memo(function ({ + data, + id, + isConnectable, + selected, +}) { + const store = useContext(RepoContext); + if (!store) throw new Error("Missing BearContext.Provider in the tree"); + // const pod = useStore(store, (state) => state.pods[id]); + const wsRun = useStore(store, (state) => state.wsRun); + const clearResults = useStore(store, (s) => s.clearResults); + // right, bottom + const [layout, setLayout] = useState("bottom"); + const isRightLayout = layout === "right"; + const setPodName = useStore(store, (state) => state.setPodName); + const getPod = useStore(store, (state) => state.getPod); + const setCutting = useStore(store, (state) => state.setCutting); + const pod = getPod(id); + const role = useStore(store, (state) => state.role); + const width = useStore(store, (state) => state.pods[id]?.width); + const isPodFocused = useStore(store, (state) => state.pods[id]?.focus); + const index = useStore( + store, + (state) => state.pods[id]?.result?.count || " " + ); + const inputRef = useRef(null); + + const showResult = useStore( + store, + (state) => + state.pods[id]?.running || + state.pods[id]?.result || + state.pods[id]?.error || + state.pods[id]?.stdout || + state.pods[id]?.stderr + ); + const nodesMap = useStore(store, (state) => state.ydoc.getMap("pods")); + const onResize = useCallback( + (e, data) => { + const { size } = data; + const node = nodesMap.get(id); + if (node) { + node.style = { ...node.style, width: size.width }; + nodesMap.set(id, node); + } + }, + [id, nodesMap] + ); + + useEffect(() => { + if (!data.name) return; + setPodName({ id, name: data.name }); + if (inputRef?.current) { + inputRef.current.value = data.name || ""; + } + }, [data.name, setPodName, 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] + ); + + const onCut = useCallback( + (clipboardData: any) => { + onCopy(clipboardData); + setCutting(id); + }, + [onCopy, setCutting, id] + ); + + if (!pod) return null; + + // onsize is banned for a guest, FIXME: ugly code + const Wrap = (child) => + role === RoleType.GUEST ? ( + <>{child} + ) : ( + + {child} + + ); + + return Wrap( + + + + + + {/* The header of code pods. */} + + + { + const name = e.target.value; + if (name === data.name) return; + const node = nodesMap.get(id); + if (node) { + nodesMap.set(id, { ...node, data: { ...node.data, name } }); + } + }} + inputProps={{ + style: { + padding: "0px", + textOverflow: "ellipsis", + }, + }} + > + + + [{index}] + + { + const pane = document.getElementsByClassName( + "react-flow__pane" + )[0] as HTMLElement; + if (pane) { + pane.tabIndex = 0; + pane.focus(); + } + }} + > + {role !== RoleType.GUEST && ( + + { + clearResults(id); + wsRun(id); + }} + > + + + + )} + + + + + + + + + + + + + + + {role !== RoleType.GUEST && ( + + { + nodesMap.delete(id); + }} + > + + + + )} + + { + setLayout(layout === "bottom" ? "right" : "bottom"); + }} + > + + + + + + + + {showResult && ( + + + + )} + + + ); +}); diff --git a/ui/src/components/RichNode.tsx b/ui/src/components/nodes/Rich.tsx similarity index 86% rename from ui/src/components/RichNode.tsx rename to ui/src/components/nodes/Rich.tsx index 5d331095..ccca5ef7 100644 --- a/ui/src/components/RichNode.tsx +++ b/ui/src/components/nodes/Rich.tsx @@ -14,7 +14,7 @@ import { ResizableBox } from "react-resizable"; import { useApolloClient } from "@apollo/client"; import { useStore } from "zustand"; -import { RepoContext, RoleType } from "../lib/store"; +import { RepoContext, RoleType } from "../../lib/store"; import ReactFlow, { addEdge, @@ -49,7 +49,7 @@ import DeleteIcon from "@mui/icons-material/Delete"; import ViewComfyIcon from "@mui/icons-material/ViewComfy"; import RectangleIcon from "@mui/icons-material/Rectangle"; import DisabledByDefaultIcon from "@mui/icons-material/DisabledByDefault"; -import { resetSelection } from "../lib/nodes"; +import { resetSelection } from "../../lib/nodes"; import { BoldExtension, @@ -91,7 +91,6 @@ import { AllStyledComponent } from "@remirror/styles/emotion"; import { TableExtension } from "@remirror/extension-react-tables"; import { GenIcon, IconBase } from "@remirror/react-components"; -import styles from "./canvas.style.js"; import { htmlToProsemirrorNode } from "remirror"; import { styled } from "@mui/material"; @@ -274,26 +273,11 @@ export const RichNode = memo(function ({ id, isConnectable, selected, - xPos, - yPos, }) { const store = useContext(RepoContext); if (!store) throw new Error("Missing BearContext.Provider in the tree"); // const pod = useStore(store, (state) => state.pods[id]); - const wsRun = useStore(store, (state) => state.wsRun); - const clearResults = useStore(store, (s) => s.clearResults); - const ref = useRef(null); - const [target, setTarget] = React.useState(null); - const [frame] = React.useState({ - translate: [0, 0], - }); - // right, bottom - const [layout, setLayout] = useState("bottom"); - const isRightLayout = layout === "right"; const setPodName = useStore(store, (state) => state.setPodName); - const setPodPosition = useStore(store, (state) => state.setPodPosition); - const setCurrentEditor = useStore(store, (state) => state.setCurrentEditor); - const setPodParent = useStore(store, (state) => state.setPodParent); const getPod = useStore(store, (state) => state.getPod); const pod = getPod(id); const role = useStore(store, (state) => state.role); @@ -305,15 +289,6 @@ export const RichNode = memo(function ({ ); const inputRef = useRef(null); - const showResult = useStore( - store, - (state) => - state.pods[id]?.running || - state.pods[id]?.result || - state.pods[id]?.error || - state.pods[id]?.stdout || - state.pods[id]?.stderr - ); const onResize = useCallback((e, data) => { const { size } = data; const node = nodesMap.get(id); @@ -323,16 +298,6 @@ export const RichNode = memo(function ({ } }, []); const nodesMap = useStore(store, (state) => state.ydoc.getMap("pods")); - const apolloClient = useApolloClient(); - const deletePod = useStore(store, (state) => state.deletePod); - const deleteNodeById = (id) => { - deletePod(apolloClient, { id: id, toDelete: [] }); - nodesMap.delete(id); - }; - - useEffect(() => { - setTarget(ref.current); - }, []); useEffect(() => { if (!data.name) return; @@ -342,26 +307,6 @@ export const RichNode = memo(function ({ } }, [data.name, setPodName, id]); - useEffect(() => { - // get relative position - const node = nodesMap.get(id); - if (node?.position) { - // update pods[id].position but don't trigger DB update (dirty: false) - setPodPosition({ - id, - x: node.position.x, - y: node.position.y, - dirty: false, - }); - } - }, [xPos, yPos, setPodPosition, id]); - - useEffect(() => { - if (data.parent !== undefined) { - setPodParent({ id, parent: data.parent, dirty: false }); - } - }, [data.parent, setPodParent, id]); - if (!pod) return null; // onsize is banned for a guest, FIXME: ugly code @@ -372,7 +317,7 @@ export const RichNode = memo(function ({ @@ -457,7 +402,6 @@ export const RichNode = memo(function ({ }} >
- {/* [{index}] */} (function ({ { - deleteNodeById(id); + nodesMap.delete(id); }} > diff --git a/ui/src/components/nodes/Scope.tsx b/ui/src/components/nodes/Scope.tsx new file mode 100644 index 00000000..a9775be2 --- /dev/null +++ b/ui/src/components/nodes/Scope.tsx @@ -0,0 +1,247 @@ +import { + useCallback, + useState, + useRef, + useContext, + useEffect, + memo, +} from "react"; +import * as React from "react"; +import ReactFlow, { + addEdge, + applyEdgeChanges, + applyNodeChanges, + Background, + MiniMap, + Controls, + Handle, + useReactFlow, + Position, + ConnectionMode, + MarkerType, + Node, +} from "reactflow"; +import "reactflow/dist/style.css"; + +import Box from "@mui/material/Box"; +import InputBase from "@mui/material/InputBase"; +import CircularProgress from "@mui/material/CircularProgress"; +import Tooltip from "@mui/material/Tooltip"; +import IconButton from "@mui/material/IconButton"; +import Grid from "@mui/material/Grid"; +import DeleteIcon from "@mui/icons-material/Delete"; +import Moveable from "react-moveable"; + +import { useStore } from "zustand"; + +import { RepoContext, RoleType } from "../../lib/store"; + +interface Props { + data: any; + id: string; + isConnectable: boolean; + selected: boolean; + // note that xPos and yPos are the absolute position of the node + xPos: number; + yPos: number; +} + +export const ScopeNode = memo(function ScopeNode({ + data, + id, + isConnectable, + selected, +}) { + // add resize to the node + const ref = useRef(null); + const store = useContext(RepoContext); + if (!store) throw new Error("Missing BearContext.Provider in the tree"); + const reactFlowInstance = useReactFlow(); + const setPodName = useStore(store, (state) => state.setPodName); + const setPodGeo = useStore(store, (state) => state.setPodGeo); + const [target, setTarget] = React.useState(); + const nodesMap = useStore(store, (state) => state.ydoc.getMap("pods")); + const [frame] = React.useState({ + translate: [0, 0], + }); + // const selected = useStore(store, (state) => state.pods[id]?.selected); + const role = useStore(store, (state) => state.role); + const inputRef = useRef(null); + + const onResize = useCallback(({ width, height, offx, offy }) => { + const node = nodesMap.get(id); + if (node) { + node.style = { ...node.style, width, height }; + node.position.x += offx; + node.position.y += offy; + nodesMap.set(id, node); + } + }, []); + + useEffect(() => { + setTarget(ref.current); + }, []); + + useEffect(() => { + if (!data.name) return; + setPodName({ id, name: data.name || "" }); + if (inputRef?.current) { + inputRef.current.value = data.name; + } + }, [data.name, id, setPodName]); + + return ( + + + {role !== RoleType.GUEST && ( + + { + // This does not work, will throw "Parent node + // jqgdsz2ns6k57vich0bf not found" when deleting a scope. + // + // nodesMap.delete(id); + // + // But this works: + reactFlowInstance.deleteElements({ nodes: [{ id }] }); + }} + > + + + + )} + + + {/* The header of scope nodes. */} + + + + {/* + + */} + + + + { + const name = e.target.value; + if (name === data.name) return; + const node = nodesMap.get(id); + if (node) { + nodesMap.set(id, { + ...node, + data: { ...node.data, name }, + }); + } + // setPodName({ id, name }); + }} + inputRef={inputRef} + disabled={role === RoleType.GUEST} + inputProps={{ + style: { + padding: "0px", + textAlign: "center", + textOverflow: "ellipsis", + }, + }} + > + + + + + + + + + {selected && role !== RoleType.GUEST && ( + { + e.setOrigin(["%", "%"]); + e.dragStart && e.dragStart.set(frame.translate); + }} + onResize={(e) => { + const beforeTranslate = e.drag.beforeTranslate; + frame.translate = beforeTranslate; + e.target.style.width = `${e.width}px`; + e.target.style.height = `${e.height}px`; + e.target.style.transform = `translate(${beforeTranslate[0]}px, ${beforeTranslate[1]}px)`; + onResize({ + width: e.width, + height: e.height, + offx: beforeTranslate[0], + offy: beforeTranslate[1], + }); + setPodGeo(id, { + width: e.width, + height: e.height, + }); + }} + /> + )} + + ); +}); diff --git a/ui/src/lib/fetch.tsx b/ui/src/lib/fetch.tsx index 07234983..bdcd1a60 100644 --- a/ui/src/lib/fetch.tsx +++ b/ui/src/lib/fetch.tsx @@ -180,10 +180,6 @@ function serializePodInput(pod) { utility, name, error, - imports, - exports, - reexports, - midports, x, y, width, @@ -201,10 +197,6 @@ function serializePodInput(pod) { content: JSON.stringify(content), // result: JSON.stringify(result), // error: JSON.stringify(error), - imports: JSON.stringify(imports), - exports: JSON.stringify(exports), - reexports: JSON.stringify(reexports), - midports: JSON.stringify(midports), x, y, width, diff --git a/ui/src/lib/nodes.tsx b/ui/src/lib/nodes.tsx index 704efd6a..7e263f68 100644 --- a/ui/src/lib/nodes.tsx +++ b/ui/src/lib/nodes.tsx @@ -2,10 +2,8 @@ import { useCallback, useEffect, useState, useContext } from "react"; import { applyNodeChanges, Node } from "reactflow"; import { RepoContext, RoleType } from "./store"; import { useStore } from "zustand"; - -const isNodeAddChange = (change) => change.type === "add"; -const isNodeRemoveChange = (change) => change.type === "remove"; -const isNodeResetChange = (change) => change.type === "reset"; +import { useApolloClient } from "@apollo/client"; +import { Transaction, YEvent } from "yjs"; const selectedPods = new Set(); @@ -20,13 +18,14 @@ export function resetSelection() { return true; } -export function useNodesStateSynced(nodeList) { +export function useNodesStateSynced() { const store = useContext(RepoContext); if (!store) throw new Error("Missing BearContext.Provider in the tree"); const addPod = useStore(store, (state) => state.addPod); const getPod = useStore(store, (state) => state.getPod); const deletePod = useStore(store, (state) => state.deletePod); - const updatePod = useStore(store, (state) => state.updatePod); + const setPodGeo = useStore(store, (state) => state.setPodGeo); + const apolloClient = useApolloClient(); const role = useStore(store, (state) => state.role); const ydoc = useStore(store, (state) => state.ydoc); const nodesMap = ydoc.getMap("pods"); @@ -35,120 +34,148 @@ export function useNodesStateSynced(nodeList) { (state) => state.provider?.awareness?.clientID ); - const [nodes, setNodes] = useState(nodeList); + const [nodes, setNodes] = useState([]); // const setNodeId = useStore((state) => state.setSelectNode); // const selected = useStore((state) => state.selectNode); - function selectPod(id, selected) { - if (selected) { - const p = getPod(id)?.parent; + const selectPod = useCallback( + (id, selected) => { + if (selected) { + const p = getPod(id)?.parent; - // if you select a node that has a different parent, clear all previous selections - if (parent !== undefined && parent !== p) { - selectedPods.clear(); - setNodes((nds) => nds.map((n) => ({ ...n, selected: false }))); - } - parent = p; - selectedPods.add(id); - } else { - if (!selectedPods.delete(id)) return; - if (selectedPods.size === 0) parent = undefined; - } - setNodes((nds) => nds.map((n) => (n.id === id ? { ...n, selected } : n))); - } - - const onNodesChanges = useCallback((changes) => { - const nodes = Array.from(nodesMap.values()); - - const nextNodes = applyNodeChanges(changes, nodes); - - // prevent updates from guest users - if (role === RoleType.GUEST) { - setNodes(nextNodes); - return; - } - - changes.forEach((change) => { - if (!isNodeAddChange(change)) { - if (isNodeRemoveChange(change)) { - nodesMap.delete(change.id); - return; + // if you select a node that has a different parent, clear all previous selections + if (parent !== undefined && parent !== p) { + selectedPods.clear(); + setNodes((nds) => nds.map((n) => ({ ...n, selected: false }))); } - const node = nextNodes.find((n) => n.id === change.id); + parent = p; + selectedPods.add(id); + } else { + if (!selectedPods.delete(id)) return; + if (selectedPods.size === 0) parent = undefined; + } + setNodes((nds) => nds.map((n) => (n.id === id ? { ...n, selected } : n))); + }, + [getPod] + ); - if (!node) return; + const onNodesChange = useCallback( + (changes) => { + const nodes = Array.from(nodesMap.values()); - if (isNodeResetChange(change) || change.type === "select") { - selectPod(node.id, change.selected); - return; - } + const nextNodes = applyNodeChanges(changes, nodes); + + // prevent updates from guest users + if (role === RoleType.GUEST) { + setNodes(nextNodes); + return; + } - if (change.type === "dimensions" && node.type === "code") { - // There is a (seemingly unnecessary) dimension change at the very - // beginning of canvas page, which causes dirty status of all - // CodeNodes to be set. This is a workaround to prevent that. - if (getPod(node.id).width !== node.width) { - // only sync width - updatePod({ - id: node.id, - data: { - width: node.style?.width as number, - }, - }); + changes.forEach((change) => { + if (change.type !== "add") { + if (change.type === "remove") { + nodesMap.delete(change.id); + return; + } + const node = nextNodes.find((n) => n.id === change.id); + if (!node) return; + if (change.type === "reset" || change.type === "select") { + selectPod(node.id, change.selected); + return; } - return; - } - if (node) { - nodesMap.set(change.id, node); + if (node) { + nodesMap.set(change.id, node); + } } - } - }); - }, []); + }); + }, + [nodesMap, role, selectPod] + ); + + const triggerUpdate = useCallback(() => { + setNodes( + Array.from(nodesMap.values()) + .filter( + (node) => + !node.data.hasOwnProperty("clientId") || + node.data.clientId === clientId + ) + .sort((a: Node, b: Node) => a.data.level - b.data.level) + .map((node) => ({ + ...node, + selected: selectedPods.has(node.id), + hidden: node.data?.hidden === clientId, + })) + ); + }, [clientId, nodesMap]); useEffect(() => { - const observer = (YMapEvent) => { + const observer = (YMapEvent: YEvent, transaction: Transaction) => { YMapEvent.changes.keys.forEach((change, key) => { - if (change.action === "add") { - const node = nodesMap.get(key); - if (!node || node.data?.clientId || getPod(key)) return; - addPod(null, { - id: node.id, - children: [], - parent: "ROOT", - type: node.type === "code" ? "CODE" : "DECK", - lang: "python", - x: node.position.x, - y: node.position.y, - width: node.style?.width as number, - height: node.style?.height as number, - name: node.data?.name, - dirty: false, - }); - } else if (change.action === "delete") { - const node = change.oldValue; - console.log("todelete", node); - deletePod(null, { id: node.id, toDelete: [] }); + switch (change.action) { + case "add": + { + const node = nodesMap.get(key); + if (!node || node.data?.clientId || getPod(key)) return; + addPod(null, { + id: node.id, + children: [], + parent: "ROOT", + type: node.type === "code" ? "CODE" : "DECK", + lang: "python", + x: node.position.x, + y: node.position.y, + width: node.width!, + height: node.height!, + name: node.data?.name, + dirty: false, + }); + } + break; + case "delete": + { + const node = change.oldValue; + if (transaction.local && !node.data?.clientId) { + // If the delete is made by the current user, and it is not a + // pasting node, delete it from the server. + deletePod(apolloClient, { id: node.id }); + } else { + deletePod(null, { id: node.id }); + } + } + break; + case "update": + { + const node = nodesMap.get(key); + if (!node) { + console.error("Node not found", key); + break; + } + // The node is a node pasting from this user or other users. + if (node.data?.clientId) break; + setPodGeo( + key, + { + parent: node.parentNode ? node.parentNode : "ROOT", + x: node.position.x, + y: node.position.y, + width: node.width!, + height: node.height!, + }, + transaction.local ? true : false + ); + } + break; + default: + throw new Error("Unknown action", change.action); } }); + // TOFIX: a node may be shadowed behind its parent, due to the order to + // render reactflow node, to fix this, comment out the following sorted + // method, which brings in a large overhead. - // TOFIX: a node may be shadowed behind its parent, due to the order to render reactflow node, to fix this, comment out the following sorted method, which brings in a large overhead. - setNodes( - 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) - .map((node) => ({ - ...node, - selected: selectedPods.has(node.id), - hidden: node.data?.hidden === clientId, - })) - ); - - // setNodes(Array.from(nodesMap.values())); + triggerUpdate(); }; // setNodes(Array.from(nodesMap.values())); @@ -158,7 +185,21 @@ export function useNodesStateSynced(nodeList) { nodesMap.unobserve(observer); resetSelection(); }; - }, []); - - return [nodes.filter((n) => n), setNodes, onNodesChanges]; + }, [ + addPod, + apolloClient, + clientId, + deletePod, + getPod, + nodesMap, + setPodGeo, + triggerUpdate, + ]); + + return { + nodes: nodes.filter((n) => n), + onNodesChange, + setNodes, + triggerUpdate, + }; } diff --git a/ui/src/lib/parser.tsx b/ui/src/lib/parser.tsx index 4e4d3a26..8208b233 100644 --- a/ui/src/lib/parser.tsx +++ b/ui/src/lib/parser.tsx @@ -50,6 +50,8 @@ export type Annotation = { export function analyzeCodeViaQuery(code) { let annotations: Annotation[] = []; let ispublic = false; + // FIXME better error handling + if (!code) return { ispublic, annotations }; if (code.trim().startsWith("@export")) { ispublic = true; code = code.replace("@export", " ".repeat("@export".length)); @@ -97,6 +99,8 @@ export function analyzeCodeViaQuery(code) { export function analyzeCode(code) { let annotations: Annotation[] = []; let ispublic = false; + // FIXME better error handling + if (!code) return { ispublic, annotations }; if (code.trim().startsWith("@export")) { ispublic = true; code = code.replace("@export", " ".repeat("@export".length)); diff --git a/ui/src/lib/store/index.tsx b/ui/src/lib/store/index.tsx index 71c5e805..a94a4bd1 100644 --- a/ui/src/lib/store/index.tsx +++ b/ui/src/lib/store/index.tsx @@ -37,8 +37,8 @@ export type Pod = { ispublic?: boolean; x: number; y: number; - width: number; - height: number; + width?: number; + height?: number; ns?: string; running?: boolean; focus?: boolean; diff --git a/ui/src/lib/store/podSlice.tsx b/ui/src/lib/store/podSlice.tsx index 0192d4be..a0900905 100644 --- a/ui/src/lib/store/podSlice.tsx +++ b/ui/src/lib/store/podSlice.tsx @@ -1,26 +1,9 @@ import { createStore, StateCreator, StoreApi } from "zustand"; -import { devtools } from "zustand/middleware"; import produce from "immer"; -import { createContext } from "react"; -import { - normalize, - doRemoteLoadRepo, - doRemoteUpdatePod, - doRemoteAddPod, - doRemoteDeletePod, - doRemoteLoadVisibility, - doRemoteUpdateVisibility, - doRemoteAddCollaborator, - doRemoteDeleteCollaborator, -} from "../fetch"; +import { doRemoteAddPod, doRemoteDeletePod } from "../fetch"; -import { Doc } from "yjs"; -import { WebsocketProvider } from "y-websocket"; -import { createRuntimeSlice, RuntimeSlice } from "./runtimeSlice"; import { ApolloClient } from "@apollo/client"; -import { addAwarenessStyle } from "../styles"; -import { Annotation } from "../parser"; import { Pod, MyState } from "."; @@ -30,35 +13,33 @@ export interface PodSlice { getId2children: (string) => string[]; setPodFocus: (id: string) => void; setPodBlur: (id: string) => void; - updatePod: ({ id, data }: { id: string; data: Partial }) => void; + setPodGeo: ( + id: string, + { + x, + y, + width, + height, + parent, + }: { + x?: number; + y?: number; + + width?: number; + + height?: number; + parent?: string; + }, + dirty?: boolean + ) => void; setPodName: ({ id, name }: { id: string; name: string }) => void; setPodContent: ({ id, content }: { id: string; content: string }) => void; initPodContent: ({ id, content }: { id: string; content: string }) => void; addPod: (client: ApolloClient | null, pod: Pod) => void; deletePod: ( client: ApolloClient | null, - { id, toDelete }: { id: string; toDelete: string[] } + { id }: { id: string } ) => Promise; - setPodPosition: ({ - id, - x, - y, - dirty, - }: { - id: string; - x: number; - y: number; - dirty: boolean; - }) => void; - setPodParent: ({ - id, - parent, - dirty, - }: { - id: string; - parent: string; - dirty: boolean; - }) => void; setPodResult: ({ id, content, @@ -92,17 +73,6 @@ export const createPodSlice: StateCreator = ( // @ts-ignore "setPodName" ), - setPodLang: ({ id, lang }) => - set( - produce((state) => { - let pod = state.pods[id]; - pod.lang = lang; - pod.dirty = true; - }), - false, - // @ts-ignore - "setPodLang" - ), setPodContent: ({ id, content }) => set( produce((state) => { @@ -130,27 +100,42 @@ export const createPodSlice: StateCreator = ( state.pods[id].render = value; }) ), - setPodPosition: ({ id, x, y, dirty = true }) => + setPodGeo: (id, { x, y, width, height, parent }, dirty = true) => set( produce((state) => { let pod = state.pods[id]; + // 0. check if the update is necessary + if ( + pod.x === x && + pod.y === y && + pod.width === width && + pod.height === height && + pod.parent === parent + ) { + return; + } + // 1. check if parent is updated. If so, update the children list. + if (parent && parent !== pod.parent) { + if (!state.pods[parent]) { + throw new Error(`parent pod ${parent} not found`); + } + const oldparent = state.pods[state.pods[id].parent]; + pod.parent = parent; + state.pods[parent].children.push(state.pods[id]); + let idx = oldparent.children.findIndex(({ id: _id }) => _id === id); + oldparent.children.splice(idx, 1); + } + // 2. update x,y,width,height pod.x = x; pod.y = y; + pod.width = width; + pod.height = height; + // Update the dirty flag. pod.dirty ||= dirty; }), false, // @ts-ignore - "setPodPosition" - ), - updatePod: ({ id, data }) => - set( - produce((state) => { - state.pods[id] = { ...state.pods[id], ...data }; - state.pods[id].dirty = true; - }), - false, - // @ts-ignore - "updatePod" + "setPodGeo" ), setPodStdout: ({ id, stdout }) => set( @@ -259,23 +244,6 @@ export const createPodSlice: StateCreator = ( } }) ), - setPodParent: ({ id, parent, dirty = true }) => - set( - produce((state) => { - // FIXME I need to modify many pods here. - if (state.pods[id]?.parent === parent) return; - const oldparent = state.pods[state.pods[id].parent]; - state.pods[id].parent = parent; - // FXME I'm marking all the pods as dirty here. - state.pods[id].dirty ||= dirty; - state.pods[parent].children.push(state.pods[id]); - let idx = oldparent.children.findIndex(({ id: _id }) => _id === id); - oldparent.children.splice(idx, 1); - }), - false, - // @ts-ignore - "setPodParent" - ), resizeScope: ({ id }) => set( produce((state) => { @@ -342,9 +310,10 @@ function addPod(set, get: () => MyState) { function deletePod(set, get) { return async ( client: ApolloClient | null, - { id, toDelete }: { id: string; toDelete: string[] } + { id }: { id: string } ) => { const pods = get().pods; + const toDelete: string[] = []; // get all ids to delete. Gathering them here is easier than on the server diff --git a/ui/src/lib/store/repoMetaSlice.tsx b/ui/src/lib/store/repoMetaSlice.tsx index e73981e2..02d5482f 100644 --- a/ui/src/lib/store/repoMetaSlice.tsx +++ b/ui/src/lib/store/repoMetaSlice.tsx @@ -6,14 +6,6 @@ import { WebsocketProvider } from "y-websocket"; import { MyState } from "."; import { gql } from "@apollo/client"; -let serverURL; -if (window.location.protocol === "http:") { - serverURL = `ws://${window.location.host}/socket`; -} else { - serverURL = `wss://${window.location.host}/socket`; -} -console.log("yjs server url: ", serverURL); - export interface RepoMetaSlice { repoName: string | null; repoNameSyncing: boolean; @@ -38,21 +30,6 @@ export const createRepoMetaSlice: StateCreator< set( produce((state: MyState) => { state.repoId = repoId; - state.ydoc = new Doc(); - // console.log("user reset state setrepo", repoId); - if (state.provider) { - console.log("emmm, provider exists", state.provider); - } else { - console.log("connecting yjs socket .."); - state.provider = new WebsocketProvider( - serverURL, - state.repoId, - state.ydoc - ); - // max retry time: 10s - state.provider.connect(); - state.provider.maxBackoffTime = 10000; - } }) ), setRepoName: (name) => { diff --git a/ui/src/lib/store/repoStateSlice.tsx b/ui/src/lib/store/repoStateSlice.tsx index ce5e1a27..4ae83aff 100644 --- a/ui/src/lib/store/repoStateSlice.tsx +++ b/ui/src/lib/store/repoStateSlice.tsx @@ -23,6 +23,14 @@ import { addAwarenessStyle } from "../styles"; import { Annotation } from "../parser"; import { MyState, Pod, RoleType } from "."; +let serverURL; +if (window.location.protocol === "http:") { + serverURL = `ws://${window.location.host}/socket`; +} else { + serverURL = `wss://${window.location.host}/socket`; +} +console.log("yjs server url: ", serverURL); + export interface RepoStateSlice { pods: Record; id2parent: Record; @@ -60,6 +68,7 @@ export interface RepoStateSlice { remoteUpdateAllPods: (client) => void; showLineNumbers: boolean; flipShowLineNumbers: () => void; + connectYjs: () => void; disconnectYjs: () => void; } @@ -189,11 +198,34 @@ export const createRepoStateSlice: StateCreator< return { success, error }; }, setCutting: (id: string | null) => set({ cutting: id }), + connectYjs: () => + set( + produce((state) => { + console.log("connecting yjs socket .."); + state.ydoc = new Doc(); + // console.log("user reset state setrepo", repoId); + if (state.provider) { + console.log("emmm, provider exists", state.provider); + } else { + console.log("connecting yjs socket .."); + state.provider = new WebsocketProvider( + serverURL, + state.repoId, + state.ydoc + ); + // max retry time: 10s + state.provider.connect(); + state.provider.maxBackoffTime = 10000; + } + }) + ), disconnectYjs: () => set( // clean up the connected provider after exiting the page produce((state) => { + console.log("disconnecting yjs socket .."); if (state.provider) { + console.log("destroying Yjs provider"); state.provider.destroy(); // just for debug usage, remove it later state.provider = null; diff --git a/ui/src/pages/repo.tsx b/ui/src/pages/repo.tsx index 7c418f69..d560c080 100644 --- a/ui/src/pages/repo.tsx +++ b/ui/src/pages/repo.tsx @@ -292,7 +292,6 @@ function RepoImpl() { }, [parseAllPods, parserLoaded, repoLoaded, resolveAllPods, scopedVars]); useEffect(() => { - setRepo(id!); if (hasToken()) { if (!loading && me) { setUser(me); @@ -337,14 +336,19 @@ function RepoImpl() { } export default function Repo() { + let { id } = useParams(); const store = useRef(createRepoStore()).current; const disconnectYjs = useStore(store, (state) => state.disconnectYjs); + const connectYjs = useStore(store, (state) => state.connectYjs); + const setRepo = useStore(store, (state) => state.setRepo); // console.log("load store", useRef(createRepoStore())); useEffect(() => { + setRepo(id!); + connectYjs(); // const provider = useStore(store, (state) => state.provider); // clean up the connected provider after exiting the page return disconnectYjs; - }, [disconnectYjs, store]); + }, [connectYjs, disconnectYjs, id, setRepo, store]); return (