diff --git a/api/prisma/migrations/20230501222052_add_isguset_field/migration.sql b/api/prisma/migrations/20230501222052_add_isguset_field/migration.sql new file mode 100644 index 00000000..cf267c14 --- /dev/null +++ b/api/prisma/migrations/20230501222052_add_isguset_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "isGuest" BOOLEAN NOT NULL DEFAULT false; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 264f5ee1..1a03e5d6 100755 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -42,6 +42,7 @@ model User { lastname String // A user might not have a password, if they login via OAuth. hashedPassword String? + isGuest Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt diff --git a/api/src/resolver_repo.ts b/api/src/resolver_repo.ts index 2fcb86bd..20a49254 100644 --- a/api/src/resolver_repo.ts +++ b/api/src/resolver_repo.ts @@ -293,11 +293,7 @@ async function updateRepo(_, { id, name }, { userId }) { async function deleteRepo(_, { id }, { userId }) { if (!userId) throw Error("Unauthenticated"); - const user = await prisma.user.findFirst({ - where: { - id: userId, - }, - }); + // only a repo owner can delete a repo. const repo = await prisma.repo.findFirst({ where: { id, @@ -315,6 +311,15 @@ async function deleteRepo(_, { id }, { userId }) { }, }, }); + // 2. delete UserRepoData + await prisma.userRepoData.deleteMany({ + where: { + repo: { + id: repo.id, + }, + }, + }); + // 3. delete the repo itself await prisma.repo.delete({ where: { id: repo.id, @@ -401,14 +406,15 @@ async function updatePod(_, { id, repoId, input }, { userId }) { }, data: { ...input, - parent: - input.parent && input.parent !== "ROOT" - ? { + parent: input.parent + ? input.parent === "ROOT" + ? { disconnect: true } + : { connect: { id: input.parent, }, } - : undefined, + : undefined, children: { connect: input.children?.map((id) => ({ id })), }, diff --git a/api/src/resolver_user.ts b/api/src/resolver_user.ts index 4d5e0902..abf98228 100644 --- a/api/src/resolver_user.ts +++ b/api/src/resolver_user.ts @@ -38,7 +38,49 @@ async function signup(_, { email, password, firstname, lastname }) { }); return { token: jwt.sign({ id: user.id }, process.env.JWT_SECRET as string, { - expiresIn: "7d", + expiresIn: "30d", + }), + }; +} + +/** + * Create a guest user and return a token. The guest user doesn't have a password or email. + */ +async function signupGuest(_, {}) { + const id = await nanoid(); + const user = await prisma.user.create({ + data: { + id: id, + email: id + "@example.com", + firstname: "Guest", + lastname: "Guest", + isGuest: true, + }, + }); + return { + // CAUTION the front-end should save the user ID so that we can login again after expiration. + token: jwt.sign({ id: user.id }, process.env.JWT_SECRET as string, { + expiresIn: "30d", + }), + }; +} + +/** + * Login a user with a guest ID and no password. + */ +async function loginGuest(_, {id}) { + const user = await prisma.user.findFirst({ + where: { + id, + isGuest: true, + } + }); + if (!user) throw Error(`User does not exist`); + return { + id: user.id, + email: user.email, + token: jwt.sign({ id: user.id }, process.env.JWT_SECRET as string, { + expiresIn: "30d", }), }; } @@ -139,5 +181,7 @@ export default { loginWithGoogle, signup, updateUser, + signupGuest, + loginGuest, }, }; diff --git a/api/src/server.ts b/api/src/server.ts index 660d7103..0a55298e 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -41,6 +41,7 @@ async function startServer() { plugins: [ApolloServerPluginLandingPageLocalDefault({ embed: true })], }); const expapp = express(); + expapp.use(express.json({ limit: "20mb" })); const http_server = http.createServer(expapp); const wss = new WebSocketServer({ server: http_server }); // graphql api will be available at /graphql diff --git a/api/src/spawner-k8s.ts b/api/src/spawner-k8s.ts index 3f07a623..d6177baf 100644 --- a/api/src/spawner-k8s.ts +++ b/api/src/spawner-k8s.ts @@ -14,53 +14,24 @@ kc.loadFromDefault(); const k8sApi = kc.makeApiClient(k8s.CoreV1Api); const k8sAppsApi = kc.makeApiClient(k8s.AppsV1Api); -/** - * DEPRECATED. Create deployment instead. - */ -function getPodSpec(name) { - return { - apiVersion: "v1", - kind: "Pod", - metadata: { - name: `runtime-${name}`, - }, - spec: { - containers: [ - { - name: `runtime-${name}-kernel`, - image: process.env.ZMQ_KERNEL_IMAGE, - }, - { - name: `runtime-${name}-ws`, - image: process.env.WS_RUNTIME_IMAGE, - // The out-facing port for proxy to talk to. - ports: [{ containerPort: 4020 }], - // It will talk to the above kernel container. - env: [{ name: "ZMQ_HOST", value: `${name}-kernel` }], - }, - ], - }, - }; -} - function getDeploymentSpec(name) { return { apiVersion: "apps/v1", kind: "Deployment", metadata: { - name: `runtime-${name}-deployment`, + name: `${name}-deployment`, }, spec: { replicas: 1, selector: { - matchLabels: { app: `runtime-${name}` }, + matchLabels: { app: `${name}` }, }, template: { - metadata: { labels: { app: `runtime-${name}` } }, + metadata: { labels: { app: `${name}` } }, spec: { containers: [ { - name: `runtime-${name}-kernel`, + name: `${name}-kernel`, image: process.env.ZMQ_KERNEL_IMAGE, ports: [ // These are pre-defined in kernel/conn.json @@ -72,7 +43,7 @@ function getDeploymentSpec(name) { ], }, { - name: `runtime-${name}-ws`, + name: `${name}-ws`, image: process.env.WS_RUNTIME_IMAGE, // The out-facing port for proxy to talk to. ports: [{ containerPort: 4020 }], @@ -101,11 +72,11 @@ function getServiceSpec(name) { apiVersion: "v1", kind: "Service", metadata: { - name: `runtime-${name}-service`, + name: `${name}-service`, }, spec: { selector: { - app: `runtime-${name}`, + app: `${name}`, }, ports: [ { @@ -229,7 +200,7 @@ async function addRoute(ns: string, service_spec, url: string) { export async function spawnRuntime(_, { sessionId }) { let url = `/${sessionId}`; sessionId = sessionId.replaceAll("_", "-").toLowerCase(); - let k8s_name = `k8s-${sessionId}`; + let k8s_name = `rt-${sessionId}`; console.log("spawnRuntime", url, k8s_name); console.log("Creating namespaced pod .."); let ns = @@ -250,9 +221,7 @@ export async function spawnRuntime(_, { sessionId }) { export async function killRuntime(_, { sessionId }) { let url = `/${sessionId}`; sessionId = sessionId.replaceAll("_", "-").toLowerCase(); - // sessionId = "runtime-k8s-user-UGY6YAk7TM-repo-34prxrgkKG-kernel"; - // sessionId = "k8s-user-UGY6YAk7TM"; - let k8s_name = `k8s-${sessionId}`; + let k8s_name = `rt-${sessionId}`; console.log("killRuntime", url, k8s_name); let ns = process.env.RUNTIME_NS || @@ -308,7 +277,7 @@ export async function killRuntime(_, { sessionId }) { */ export async function infoRuntime(_, { sessionId }) { sessionId = sessionId.replaceAll("_", "-").toLowerCase(); - let k8s_name = `k8s-${sessionId}`; + let k8s_name = `rt-${sessionId}`; let ns = process.env.RUNTIME_NS || fs @@ -375,7 +344,7 @@ export function loopKillInactiveRoutes() { function deploymentName2sessionId(deploymentName: string) { // NOTE: this is sessionId.replaceAll("_", "-").toLowerCase() - return deploymentName.match(/runtime-k8s-(.*)-deployment/)![1]; + return deploymentName.match(/rt-(.*)-deployment/)![1]; } async function scanRunningSessions(): Promise { @@ -408,7 +377,7 @@ export async function initRoutes() { console.log("initRoutes sessionIds", sessionIds); for (let id of sessionIds) { let url = `/${id}`; - let k8s_name = `k8s-${id}`; + let k8s_name = `rt-${id}`; let service_spec = getServiceSpec(k8s_name); await addRoute(ns, service_spec, url); } diff --git a/api/src/typedefs.ts b/api/src/typedefs.ts index 8c06305d..0d9a3085 100644 --- a/api/src/typedefs.ts +++ b/api/src/typedefs.ts @@ -113,6 +113,8 @@ export const typeDefs = gql` type Mutation { login(email: String!, password: String!): AuthData + loginGuest(id: String!): AuthData + signupGuest: AuthData signup( email: String! password: String! diff --git a/ui/package.json b/ui/package.json index 3d8192a4..9c1c2f80 100644 --- a/ui/package.json +++ b/ui/package.json @@ -10,13 +10,9 @@ "@emotion/styled": "^11.10.5", "@mui/icons-material": "^5.10.9", "@mui/material": "^5.10.10", - "@reactflow/node-resizer": "^2.0.1", - "@remirror/extension-react-tables": "^2.2.9", - "@remirror/extension-sub": "^2.0.9", - "@remirror/extension-text-highlight": "^2.0.10", - "@remirror/pm": "^2.0.2", - "@remirror/react": "^2.0.22", - "@remirror/react-editors": "^1.0.22", + "@reactflow/node-resizer": "^2.1.0", + "@remirror/pm": "^2.0.5", + "@remirror/react": "^2.0.28", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^13.0.0", "@testing-library/user-event": "^13.2.1", @@ -26,8 +22,13 @@ "@types/react-dom": "^18.0.0", "ansi-to-react": "^6.1.6", "crypto-js": "^4.1.1", + "d3-force": "^3.0.0", + "d3-quadtree": "^3.0.1", "formik": "^2.2.9", "graphql": "^16.6.0", + "html-to-image": "^1.11.11", + "jwt-decode": "^3.1.2", + "kbar": "^0.1.0-beta.40", "monaco-editor": "^0.34.1", "monaco-editor-webpack-plugin": "^7.0.1", "nanoid-dictionary": "^4.3.0", @@ -42,8 +43,8 @@ "react-resizable": "^3.0.4", "react-router-dom": "^6.4.2", "react-scripts": "5.0.1", - "reactflow": "^11.5.6", - "remirror": "^2.0.21", + "reactflow": "^11.7.0", + "remirror": "^2.0.31", "slate": "^0.82.1", "slate-history": "^0.66.0", "slate-react": "^0.83.2", @@ -51,11 +52,12 @@ "stompjs": "^2.3.3", "ts-pattern": "^4.0.6", "typescript": "^4.4.2", - "web-tree-sitter": "^0.20.7", + "web-tree-sitter": "0.20.7", "web-vitals": "^2.1.0", "xterm": "^5.0.0", "xterm-addon-fit": "^0.6.0", "y-monaco": "^0.1.4", + "y-prosemirror": "^1.2.1", "y-websocket": "^1.4.5", "yjs": "^13.5.42", "zustand": "^4.1.3" diff --git a/ui/src/App.css b/ui/src/App.css index d1f973dd..103a9956 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -75,19 +75,61 @@ text-decoration: wavy underline red; } -.react-flow__node-scope.active { - box-shadow: 0px 0px 12px 0px rgba(100, 100, 100, 0.9); +.react-flow__node-SCOPE.active { + box-shadow: 2px 2px 2px 2px rgba(208,2,27,1), 8px 8px 8px 8px rgba(218,102,123,1); } -.react-flow__node-scope.selected, -.react-flow__node-scope:hover { +.react-flow__node-SCOPE.selected { box-shadow: 0px 0px 8px 0px rgba(100, 100, 100, 0.5); } -.react-flow__node-scope.selected { +.react-flow__node-SCOPE.selected { border-width: 2px; } .react-flow__handle { z-index: 100; } + +/* Remove the right scrollbar on Remirror. */ +.remirror-editor.ProseMirror { + overflow-y: hidden !important; +} + + +.mention { + background: #7963d266; + padding: 2px 4px; + border-radius: 4px; +} + +.remirror-theme { + /* Provide sufficient space to see the popup */ + --rmr-space-6: 400px; +} +.suggestions { + border: 1px solid darkgray; + border-radius: 4px; + background: white; + cursor: pointer; +} +.suggestion { + padding: 2px 8px; +} +.highlighted { + background: #7963d233; +} +.hovered { + background: #7963d222; +} + +.react-flow__edges { + z-index: 9999 !important; +} + +/* some scope nodes have z-index: 1000. This is set internally by reactflow, we +have to force set the collapsed scope block (when contextual zoomed) to be above +it, so that we can drag the block. */ +.react-flow__node:has(.scope-block) { + z-index: 1001 !important; +} \ No newline at end of file diff --git a/ui/src/App.tsx b/ui/src/App.tsx index e11efd33..0fb1917c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -100,6 +100,14 @@ const router = createBrowserRouter([ ), }, + { + path: "test", + element: ( + + + + ), + }, { path: "/", element: ( diff --git a/ui/src/components/Canvas.tsx b/ui/src/components/Canvas.tsx index 977687e7..fc3d261a 100644 --- a/ui/src/components/Canvas.tsx +++ b/ui/src/components/Canvas.tsx @@ -12,6 +12,7 @@ import ReactFlow, { applyEdgeChanges, applyNodeChanges, Background, + BackgroundVariant, MiniMap, Controls, Handle, @@ -22,6 +23,7 @@ import ReactFlow, { Node, ReactFlowProvider, Edge, + useViewport, } from "reactflow"; import "reactflow/dist/style.css"; @@ -33,7 +35,6 @@ import { lowercase, numbers } from "nanoid-dictionary"; import { useStore } from "zustand"; import { RepoContext } from "../lib/store"; -import { dbtype2nodetype, nodetype2dbtype } from "../lib/utils"; import { useEdgesYjsObserver, useYjsObserver } from "../lib/nodes"; import { useApolloClient } from "@apollo/client"; @@ -45,8 +46,9 @@ import { ScopeNode } from "./nodes/Scope"; import { YMap } from "yjs/dist/src/types/YMap"; import FloatingEdge from "./nodes/FloatingEdge"; import CustomConnectionLine from "./nodes/CustomConnectionLine"; +import HelperLines from "./HelperLines"; -const nodeTypes = { scope: ScopeNode, code: CodeNode, rich: RichNode }; +const nodeTypes = { SCOPE: ScopeNode, CODE: CodeNode, RICH: RichNode }; const edgeTypes = { floating: FloatingEdge, }; @@ -62,7 +64,7 @@ function store2nodes(id: string, { getId2children, getPod }) { if (id !== "ROOT") { res.push({ id: id, - type: dbtype2nodetype(pod.type), + type: pod.type, data: { // label: `ID: ${id}, parent: ${pods[id].parent}, pos: ${pods[id].x}, ${pods[id].y}`, label: id, @@ -72,7 +74,6 @@ function store2nodes(id: string, { getId2children, getPod }) { // position: { x: 100, y: 100 }, position: { x: pod.x, y: pod.y }, parentNode: pod.parent !== "ROOT" ? pod.parent : undefined, - extent: pod.parent !== "ROOT" ? "parent" : undefined, style: { width: pod.width || undefined, height: pod.height || undefined, @@ -205,6 +206,10 @@ function useInitNodes() { const updateView = useStore(store, (state) => state.updateView); const updateEdgeView = useStore(store, (state) => state.updateEdgeView); const adjustLevel = useStore(store, (state) => state.adjustLevel); + const buildNode2Children = useStore( + store, + (state) => state.buildNode2Children + ); useEffect(() => { const init = () => { let nodes = store2nodes("ROOT", { getId2children, getPod }); @@ -213,12 +218,6 @@ function useInitNodes() { console.warn( "The yjs server is not consistent with the database. Resetting the yjs server" ); - // throw new Error("Inconsistent state"); - // - // CAUTION should not use nodesMap.clear(), as it would delete all - // nodes! Both local and in database. - let nodesMap2 = new Map(); - nodes.forEach((node) => nodesMap2.set(node.id, node)); // Not only should we set nodes, but also delete. nodesMap.clear(); // add the nodes, so that the nodesMap is consistent with the database. @@ -260,6 +259,7 @@ function useInitNodes() { } updateEdgeView(); setLoading(false); + buildNode2Children(); }; if (!provider) return; @@ -467,6 +467,30 @@ function CanvasImplWrap() { return ( + + + ); +} + +function ViewportInfo() { + const store = useContext(RepoContext); + if (!store) throw new Error("Missing BearContext.Provider in the tree"); + const { x, y, zoom } = useViewport(); + return ( + + {`x: ${x.toFixed(2)}, y: ${y.toFixed(2)}, zoom: ${zoom.toFixed(2)}`} ); } @@ -497,6 +521,8 @@ function CanvasImpl() { store, (state) => state.removeDragHighlight ); + const updateView = useStore(store, (state) => state.updateView); + const autoLayoutROOT = useStore(store, (state) => state.autoLayoutROOT); const addNode = useStore(store, (state) => state.addNode); const reactFlowInstance = useReactFlow(); @@ -524,10 +550,22 @@ function CanvasImpl() { const [showContextMenu, setShowContextMenu] = useState(false); const [points, setPoints] = useState({ x: 0, y: 0 }); const [client, setClient] = useState({ x: 0, y: 0 }); + const [parentNode, setParentNode] = useState("ROOT"); const onPaneContextMenu = (event) => { event.preventDefault(); setShowContextMenu(true); + setParentNode("ROOT"); + setPoints({ x: event.pageX, y: event.pageY }); + setClient({ x: event.clientX, y: event.clientY }); + }; + + const onNodeContextMenu = (event, node) => { + if (node?.type !== "SCOPE") return; + + event.preventDefault(); + setShowContextMenu(true); + setParentNode(node.id); setPoints({ x: event.pageX, y: event.pageY }); setClient({ x: event.clientX, y: event.clientY }); }; @@ -555,6 +593,16 @@ function CanvasImpl() { }, [setShowContextMenu, setPaneFocus, setPaneBlur]); const getScopeAtPos = useStore(store, (state) => state.getScopeAtPos); + const autoRunLayout = useStore(store, (state) => state.autoRunLayout); + + const helperLineHorizontal = useStore( + store, + (state) => state.helperLineHorizontal + ); + const helperLineVertical = useStore( + store, + (state) => state.helperLineVertical + ); return ( { - // removeDragHighlight(); + removeDragHighlight(); let mousePos = project({ x: event.clientX, y: event.clientY }); - // check if the mouse is still inside this node. If not, the user - // has beenn trying to move a pod out. - if ( - mousePos.x < node.positionAbsolute!.x || - mousePos.y < node.positionAbsolute!.y || - mousePos.x > node.positionAbsolute!.x + node.width! || - mousePos.y > node.positionAbsolute!.y + node.height! - ) { - console.log("Cannot drop outside parent scope"); - return; - } let scope = getScopeAtPos(mousePos, node.id); - if (scope && scope.id !== node.parentNode) { - moveIntoScope(node.id, scope.id); + let toScope = scope ? scope.id : "ROOT"; + const parentScope = node.parentNode ? node.parentNode : "ROOT"; + if (toScope !== parentScope) { + moveIntoScope(node.id, toScope); + } + // update view manually to remove the drag highlight. + updateView(); + // run auto layout on drag stop + if (autoRunLayout) { + autoLayoutROOT(); } }} onNodeDrag={(event, node) => { let mousePos = project({ x: event.clientX, y: event.clientY }); let scope = getScopeAtPos(mousePos, node.id); if (scope) { + // The view is updated at the node position change. setDragHighlight(scope.id); } else { removeDragHighlight(); @@ -603,6 +649,7 @@ function CanvasImpl() { maxZoom={10} minZoom={0.1} onPaneContextMenu={onPaneContextMenu} + onNodeContextMenu={onNodeContextMenu} nodeTypes={nodeTypes} // custom edge for easy connect edgeTypes={edgeTypes} @@ -635,8 +682,8 @@ function CanvasImpl() { { if (n.style?.borderColor) return n.style.borderColor; - if (n.type === "code") return "#d6dee6"; - if (n.type === "scope") return "#f4f6f8"; + if (n.type === "CODE") return "#d6dee6"; + if (n.type === "SCOPE") return "#f4f6f8"; return "#d6dee6"; }} @@ -649,7 +696,25 @@ function CanvasImpl() { /> + + + + {showContextMenu && ( @@ -657,17 +722,22 @@ function CanvasImpl() { x={points.x} y={points.y} addCode={() => - addNode("code", project({ x: client.x, y: client.y })) + addNode("CODE", project({ x: client.x, y: client.y }), parentNode) } addScope={() => - addNode("scope", project({ x: client.x, y: client.y })) + addNode( + "SCOPE", + project({ x: client.x, y: client.y }), + parentNode + ) } addRich={() => - addNode("rich", project({ x: client.x, y: client.y })) + addNode("RICH", project({ x: client.x, y: client.y }), parentNode) } onShareClick={() => { setShareOpen(true); }} + parentNode={null} /> )} {shareOpen && } diff --git a/ui/src/components/HelperLines.tsx b/ui/src/components/HelperLines.tsx new file mode 100644 index 00000000..adf5734a --- /dev/null +++ b/ui/src/components/HelperLines.tsx @@ -0,0 +1,68 @@ +import { CSSProperties, useEffect, useRef } from "react"; +import { ReactFlowState, useStore } from "reactflow"; + +const canvasStyle: CSSProperties = { + width: "100%", + height: "100%", + position: "absolute", + zIndex: 10, + pointerEvents: "none", +}; + +const storeSelector = (state: ReactFlowState) => ({ + width: state.width, + height: state.height, + transform: state.transform, +}); + +export type HelperLinesProps = { + horizontal?: number; + vertical?: number; +}; + +// a simple component to display the helper lines +// it puts a canvas on top of the React Flow pane and draws the lines using the canvas API +function HelperLinesRenderer({ horizontal, vertical }: HelperLinesProps) { + const { width, height, transform } = useStore(storeSelector); + + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + + if (!ctx || !canvas) { + return; + } + + const dpi = window.devicePixelRatio; + canvas.width = width * dpi; + canvas.height = height * dpi; + + ctx.scale(dpi, dpi); + ctx.clearRect(0, 0, width, height); + ctx.strokeStyle = "#0041d0"; + + if (typeof vertical === "number") { + ctx.moveTo(vertical * transform[2] + transform[0], 0); + ctx.lineTo(vertical * transform[2] + transform[0], height); + ctx.stroke(); + } + + if (typeof horizontal === "number") { + ctx.moveTo(0, horizontal * transform[2] + transform[1]); + ctx.lineTo(width, horizontal * transform[2] + transform[1]); + ctx.stroke(); + } + }, [width, height, transform, horizontal, vertical]); + + return ( + + ); +} + +export default HelperLinesRenderer; diff --git a/ui/src/components/MyKBar.tsx b/ui/src/components/MyKBar.tsx new file mode 100644 index 00000000..ea09e77e --- /dev/null +++ b/ui/src/components/MyKBar.tsx @@ -0,0 +1,79 @@ +import { + KBarProvider, + KBarPortal, + KBarPositioner, + KBarAnimator, + KBarSearch, + KBarResults, + useMatches, + NO_GROUP, +} from "kbar"; + +import { useStore } from "zustand"; +import { RepoContext } from "../lib/store"; +import { useContext } from "react"; + +function RenderResults() { + const { results } = useMatches(); + + return ( + + typeof item === "string" ? ( +
{item}
+ ) : ( +
+ {item.name} +
+ ) + } + /> + ); +} + +export function MyKBar() { + const store = useContext(RepoContext); + if (!store) throw new Error("Missing BearContext.Provider in the tree"); + const autoLayoutROOT = useStore(store, (state) => state.autoLayoutROOT); + const actions = [ + { + id: "auto-force", + name: "Auto Force", + keywords: "auto force", + perform: () => { + autoLayoutROOT(); + }, + }, + // { + // id: "blog", + // name: "Blog", + // shortcut: ["b"], + // keywords: "writing words", + // perform: () => (window.location.pathname = "blog"), + // }, + // { + // id: "contact", + // name: "Contact", + // shortcut: ["c"], + // keywords: "email", + // perform: () => (window.location.pathname = "contact"), + // }, + ]; + return ( + + + + + + + + + + + ); +} diff --git a/ui/src/components/MyMonaco.tsx b/ui/src/components/MyMonaco.tsx index 6499766b..6f9408a5 100644 --- a/ui/src/components/MyMonaco.tsx +++ b/ui/src/components/MyMonaco.tsx @@ -2,6 +2,7 @@ import { Position } from "monaco-editor"; import { useState, useContext, memo, useCallback, useEffect } from "react"; import MonacoEditor, { MonacoDiffEditor } from "react-monaco-editor"; import { monaco } from "react-monaco-editor"; +import { Node } from "reactflow"; import { useStore } from "zustand"; import { RepoContext } from "../lib/store"; import { MonacoBinding } from "y-monaco"; @@ -281,6 +282,8 @@ function highlightAnnotations( return "myDecoration-varuse"; case "varuse": return "myDecoration-varuse"; + case "bridge": + return "myDecoration-bridge-unused"; default: throw new Error("unknown type: " + type); } @@ -373,12 +376,12 @@ async function updateGitGutter(editor) { interface MyMonacoProps { id: string; - gitvalue: string; + fontSize: number; } export const MyMonaco = memo(function MyMonaco({ id = "0", - gitvalue = null, + fontSize = 14, }) { // there's no racket language support console.debug("[perf] rendering MyMonaco", id); @@ -535,6 +538,7 @@ export const MyMonaco = memo(function MyMonaco({ alwaysConsumeMouseWheel: false, vertical: "hidden", }, + fontSize, }} onChange={onChange} editorDidMount={onEditorDidMount} diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index aa9b1011..1d34d916 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -18,6 +18,7 @@ import { useSnackbar, VariantType } from "notistack"; import { gql, useQuery, useMutation, useApolloClient } from "@apollo/client"; import { useStore } from "zustand"; +import { MyKBar } from "./MyKBar"; import { usePrompt } from "../lib/prompt"; @@ -26,6 +27,7 @@ import { RepoContext } from "../lib/store"; import useMe from "../lib/me"; import { FormControlLabel, FormGroup, Stack, Switch } from "@mui/material"; import { getUpTime } from "../lib/utils"; +import { toSvg } from "html-to-image"; function Flex(props) { return ( @@ -47,6 +49,10 @@ function SidebarSettings() { ); const devMode = useStore(store, (state) => state.devMode); const setDevMode = useStore(store, (state) => state.setDevMode); + const autoRunLayout = useStore(store, (state) => state.autoRunLayout); + const setAutoRunLayout = useStore(store, (state) => state.setAutoRunLayout); + const contextualZoom = useStore(store, (state) => state.contextualZoom); + const setContextualZoom = useStore(store, (state) => state.setContextualZoom); return ( @@ -70,6 +76,43 @@ function SidebarSettings() { /> + + + ) => { + setAutoRunLayout(event.target.checked); + }} + /> + } + label="Auto Run Layout" + /> + + + + + ) => { + setContextualZoom(event.target.checked); + }} + /> + } + label="Contextual Zoom" + /> + + + ); +} + +function ExportButtons() { + return ( + + + + + + ); +} + export const Sidebar: React.FC = ({ width, open, @@ -468,6 +569,7 @@ export const Sidebar: React.FC = ({ const isGuest = useStore(store, (state) => state.role === "GUEST"); return ( <> + = ({ )} Export to .. - - + + Site Settings diff --git a/ui/src/components/nodes/Code.tsx b/ui/src/components/nodes/Code.tsx index 979f981d..f25f1afc 100644 --- a/ui/src/components/nodes/Code.tsx +++ b/ui/src/components/nodes/Code.tsx @@ -21,6 +21,7 @@ import ReactFlow, { MarkerType, Node, NodeProps, + useStore as useReactFlowStore, } from "reactflow"; import "reactflow/dist/style.css"; @@ -38,8 +39,12 @@ 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 KeyboardDoubleArrowRightIcon from "@mui/icons-material/KeyboardDoubleArrowRight"; +import PlayDisabledIcon from "@mui/icons-material/PlayDisabled"; import DeleteIcon from "@mui/icons-material/Delete"; import ViewComfyIcon from "@mui/icons-material/ViewComfy"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; + import { CopyToClipboard } from "react-copy-to-clipboard"; import Moveable from "react-moveable"; import { ResizableBox } from "react-resizable"; @@ -52,21 +57,76 @@ import { RepoContext } from "../../lib/store"; import { MyMonaco } from "../MyMonaco"; import { useApolloClient } from "@apollo/client"; -import { NodeResizeControl, NodeResizer } from "@reactflow/node-resizer"; +import { NodeResizeControl, NodeResizer } from "reactflow"; import "@reactflow/node-resizer/dist/style.css"; -import { ResizeIcon } from "./utils"; +import { NewPodButtons, ResizeIcon } from "./utils"; +import { timeDifference } from "../../lib/utils"; +import { ButtonGroup } from "@mui/material"; -export const ResultBlock = memo(function ResultBlock({ id }) { +function Timer({ lastExecutedAt }) { + const [counter, setCounter] = useState(0); + useEffect(() => { + const interval = setInterval(() => { + setCounter(counter + 1); + }, 1000); + return () => clearInterval(interval); + }, [counter]); + return Last executed: {timeDifference(new Date(), lastExecutedAt)}; +} + +export const ResultBlock = memo(function ResultBlock({ id, layout }) { 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 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 autoLayoutROOT = useStore(store, (state) => state.autoLayoutROOT); + useEffect(() => { + autoLayoutROOT(); + }, [running]); + const lastExecutedAt = useStore( + store, + (state) => state.pods[id]?.lastExecutedAt + ); const [showOutput, setShowOutput] = useState(true); + const hasResult = useStore( + store, + (state) => + state.pods[id]?.running || + state.pods[id]?.result || + state.pods[id]?.error || + state.pods[id]?.stdout || + state.pods[id]?.stderr + ); + const [resultScroll, setResultScroll] = useState(false); + const clearResults = useStore(store, (state) => state.clearResults); + if (!hasResult) return <>; return ( { + // e.stopPropagation(); + // }} + className={showOutput && resultScroll ? "nowheel" : ""} sx={{ + border: + showOutput && resultScroll ? "solid 1px red" : "solid 1px #d6dee6", + borderRadius: "4px", + position: "absolute", + top: layout === "right" ? 0 : "100%", + left: layout === "right" ? "100%" : 0, + ...(layout === "right" + ? { minWidth: "250px" } + : { maxWidth: "100%", minWidth: "100%" }), + boxSizing: "border-box", + backgroundColor: "white", + zIndex: 100, + padding: "0 10px", userSelect: "text", cursor: "auto", }} @@ -77,7 +137,7 @@ export const ResultBlock = memo(function ResultBlock({ id }) {
) : ( <> - {!error && ( + {lastExecutedAt && !error && ( (function ResultBlock({ id }) { borderTopLeftRadius: "20px", borderTopRightRadius: "20px", display: "flex", - fontSize: "14px", + fontSize: "0.8em", }} > +
)} @@ -125,33 +186,62 @@ export const ResultBlock = memo(function ResultBlock({ id }) { - {/* Error */} - + {/* FIXME result?.count is not correct, always 0 or 1. */} + {(stdout || (result?.text && result?.count > 0) || error) && ( + + + + + + )} + {stdout && ( - + {stdout} )} @@ -159,7 +249,7 @@ export const ResultBlock = memo(function ResultBlock({ id }) { (function ResultBlock({ id }) { {error?.stacktrace && ( StackTrace - + {error.stacktrace.join("\n")} @@ -191,7 +281,7 @@ export const ResultBlock = memo(function ResultBlock({ id }) { > (function ResultBlock({ id }) { setShowOutput(!showOutput); }} sx={{ - fontSize: 10, + fontSize: "0.8em", paddingTop: "3px", paddingBottom: "2px", lineHeight: "10px", @@ -221,17 +311,15 @@ export const ResultBlock = memo(function ResultBlock({ id }) { ); }); -function FloatingToolbar({ id }) { +function MyFloatingToolbar({ id, layout, setLayout }) { const store = useContext(RepoContext); if (!store) throw new Error("Missing BearContext.Provider in the tree"); const reactFlowInstance = useReactFlow(); const devMode = useStore(store, (state) => state.devMode); // const pod = useStore(store, (state) => state.pods[id]); const wsRun = useStore(store, (state) => state.wsRun); - const clearResults = useStore(store, (s) => s.clearResults); + const wsRunChain = useStore(store, (state) => state.wsRunChain); // right, bottom - const [layout, setLayout] = useState("bottom"); - const getPod = useStore(store, (state) => state.getPod); const isGuest = useStore(store, (state) => state.role === "GUEST"); const clonePod = useStore(store, (state) => state.clonePod); const setPaneFocus = useStore(store, (state) => state.setPaneFocus); @@ -264,13 +352,24 @@ function FloatingToolbar({ id }) { ); return ( - + + + + {!isGuest && ( { - clearResults(id); wsRun(id); }} > @@ -278,12 +377,23 @@ function FloatingToolbar({ id }) { )} + {!isGuest && ( + + { + wsRunChain(id); + }} + > + + + + )} - + @@ -294,7 +404,7 @@ function FloatingToolbar({ id }) { options={{ debug: true, format: "text/plain", onCopy: onCut } as any} > - + @@ -303,7 +413,6 @@ function FloatingToolbar({ id }) { {!isGuest && ( { // Delete all edges connected to the node. reactFlowInstance.deleteElements({ nodes: [{ id }] }); @@ -315,7 +424,6 @@ function FloatingToolbar({ id }) { )} { setLayout(layout === "bottom" ? "right" : "bottom"); }} @@ -323,6 +431,17 @@ function FloatingToolbar({ id }) { + + + ); } @@ -340,12 +459,8 @@ export const CodeNode = memo(function ({ if (!store) throw new Error("Missing BearContext.Provider in the tree"); const reactFlowInstance = useReactFlow(); const devMode = useStore(store, (state) => state.devMode); - // 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 setPodGeo = useStore(store, (state) => state.setPodGeo); const getPod = useStore(store, (state) => state.getPod); @@ -353,47 +468,45 @@ export const CodeNode = memo(function ({ const pod = getPod(id); const isGuest = useStore(store, (state) => state.role === "GUEST"); const isPodFocused = useStore(store, (state) => state.pods[id]?.focus); - const index = useStore( - store, - (state) => state.pods[id]?.result?.count || " " - ); const inputRef = useRef(null); const updateView = useStore(store, (state) => state.updateView); const isCutting = useStore(store, (state) => state.cuttingIds.has(id)); - 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 autoLayoutROOT = useStore(store, (state) => state.autoLayoutROOT); + useEffect(() => { + // Run auto-layout when the output box layout changes. + autoLayoutROOT(); + }, [layout]); - const onResize = useCallback( + const onResizeStop = useCallback( (e, data) => { const { size } = data; const node = nodesMap.get(id); if (node) { - node.style = { ...node.style, width: size.width }; - nodesMap.set(id, node); + // new width + nodesMap.set(id, { + ...node, + width: size.width, + style: { ...node.style, width: size.width }, + }); setPodGeo( id, { parent: node.parentNode ? node.parentNode : "ROOT", x: node.position.x, y: node.position.y, + // new width width: size.width!, height: node.height!, }, true ); updateView(); + autoLayoutROOT(); } }, - [id, nodesMap, setPodGeo, updateView] + [id, nodesMap, setPodGeo, updateView, autoLayoutROOT] ); const [showToolbar, setShowToolbar] = useState(false); @@ -405,6 +518,14 @@ export const CodeNode = memo(function ({ } }, [data.name, setPodName, id]); + const zoomLevel = useReactFlowStore((s) => s.transform[2]); + const contextualZoom = useStore(store, (state) => state.contextualZoom); + const level2fontsize = useStore(store, (state) => state.level2fontsize); + const threshold = useStore( + store, + (state) => state.contextualZoomParams.threshold + ); + // if (!pod) throw new Error(`Pod not found: ${id}`); if (!pod) { @@ -413,190 +534,254 @@ export const CodeNode = memo(function ({ return null; } + const node = nodesMap.get(id); + + const fontSize = level2fontsize(node?.data.level); + const parentFontSize = level2fontsize(node?.data.level - 1); + + if ( + contextualZoom && + node?.data.level > 0 && + parentFontSize * zoomLevel < threshold + ) { + // The parent scope is not shown, this node is not gonna be rendered at all. + return ; + } + + if (contextualZoom && fontSize * zoomLevel < threshold) { + // Return a collapsed block. + let text = pod.content; + if (text) { + const index = text.indexOf("\n"); + if (index !== -1) { + text = text.substring(0, index); + } + } + text = text || "Empty"; + return ( + + + {text} + + + ); + } + // onsize is banned for a guest, FIXME: ugly code const Wrap = (child) => isGuest ? ( <>{child} ) : ( - - {child} - - ); - - return Wrap( - { - setShowToolbar(true); - }} - onMouseLeave={() => { - setShowToolbar(false); - }} - > - {/* FIXME this does not support x-axis only resizing. */} - {/* - - */} - - - - - {/* The header of code pods. */} - - {devMode && ( + - {id} at ({Math.round(xPos)}, {Math.round(yPos)}, w: {pod.width}, h:{" "} - {pod.height}) + {child} - )} - - { - 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}] - - - - + + ); + + return ( + <> { + setShowToolbar(true); + }} + onMouseLeave={() => { + setShowToolbar(false); + }} sx={{ - height: "90%", + cursor: "auto", + fontSize, }} > - - {showResult && ( + {Wrap( { - // e.stopPropagation(); - // }} + id={"reactflow_node_code_" + id} sx={{ - border: "solid 1px #d6dee6", + border: "1px #d6dee6", + borderWidth: pod.ispublic ? "4px" : "2px", borderRadius: "4px", - position: "absolute", - top: isRightLayout ? 0 : "100%", - left: isRightLayout ? "100%" : 0, - maxHeight: "160px", - maxWidth: isRightLayout ? "300px" : "100%", - minWidth: isRightLayout ? "150px" : "100%", - boxSizing: "border-box", - backgroundColor: "white", - zIndex: 100, - padding: "0 10px", + borderStyle: isCutting ? "dashed" : "solid", + width: "100%", + height: "100%", + backgroundColor: "rgb(244, 246, 248)", + borderColor: isCutting + ? "red" + : pod.ispublic + ? "green" + : selected + ? "#003c8f" + : !isPodFocused + ? "#d6dee6" + : "#5e92f3", }} > - + + + + + + + + + + {/* The header of code pods. */} + + {devMode && ( + + {id} at ({Math.round(xPos)}, {Math.round(yPos)}, w:{" "} + {pod.width}, h: {pod.height}), parent: {pod.parent} level:{" "} + {node?.data.level} fontSize: {fontSize} + + )} + {/* We actually don't need the name for a pod. Users can just write comments. */} + + { + 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", + }, + }} + > + + + + + + + + + + )} - + ); }); diff --git a/ui/src/components/nodes/Rich.tsx b/ui/src/components/nodes/Rich.tsx index b45ff88b..f779156e 100644 --- a/ui/src/components/nodes/Rich.tsx +++ b/ui/src/components/nodes/Rich.tsx @@ -29,6 +29,7 @@ import ReactFlow, { ConnectionMode, MarkerType, Node, + useStore as useReactFlowStore, } from "reactflow"; import "reactflow/dist/style.css"; import Ansi from "ansi-to-react"; @@ -49,15 +50,24 @@ 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 DragIndicatorIcon from "@mui/icons-material/DragIndicator"; import { BoldExtension, CalloutExtension, + DropCursorExtension, + ImageExtension, ItalicExtension, + LinkExtension, + MentionExtension, + MentionExtensionAttributes, PlaceholderExtension, + ShortcutHandlerProps, SubExtension, SupExtension, TextHighlightExtension, + YjsExtension, + createMarkPositioner, wysiwygPreset, } from "remirror/extensions"; import { @@ -83,16 +93,250 @@ import { BaselineButtonGroup, CommandButton, CommandButtonProps, + useChainedCommands, + useCurrentSelection, + useAttrs, + useUpdateReason, + FloatingWrapper, + useMention, } from "@remirror/react"; -import { WysiwygEditor } from "@remirror/react-editors/wysiwyg"; -import { FloatingToolbar } from "@remirror/react"; -import { AllStyledComponent } from "@remirror/styles/emotion"; +import { FloatingToolbar, useExtensionEvent } from "@remirror/react"; import { TableExtension } from "@remirror/extension-react-tables"; import { GenIcon, IconBase } from "@remirror/react-components"; +import "remirror/styles/all.css"; +import "./remirror-size.css"; -import { htmlToProsemirrorNode } from "remirror"; +import { ProsemirrorPlugin, cx, htmlToProsemirrorNode } from "remirror"; import { styled } from "@mui/material"; +import { MyYjsExtension } from "./YjsRemirror"; +import { NewPodButtons } from "./utils"; + +function useLinkShortcut() { + const [linkShortcut, setLinkShortcut] = useState< + ShortcutHandlerProps | undefined + >(); + const [isEditing, setIsEditing] = useState(false); + + useExtensionEvent( + LinkExtension, + "onShortcut", + useCallback( + (props) => { + if (!isEditing) { + setIsEditing(true); + } + + return setLinkShortcut(props); + }, + [isEditing] + ) + ); + + return { linkShortcut, isEditing, setIsEditing }; +} + +function useFloatingLinkState() { + const chain = useChainedCommands(); + const { isEditing, linkShortcut, setIsEditing } = useLinkShortcut(); + const { to, empty } = useCurrentSelection(); + + const url = (useAttrs().link()?.href as string) ?? ""; + const [href, setHref] = useState(url); + + // A positioner which only shows for links. + const linkPositioner = React.useMemo( + () => createMarkPositioner({ type: "link" }), + [] + ); + + const onRemove = useCallback(() => { + return chain.removeLink().focus().run(); + }, [chain]); + + const updateReason = useUpdateReason(); + + React.useLayoutEffect(() => { + if (!isEditing) { + return; + } + + if (updateReason.doc || updateReason.selection) { + setIsEditing(false); + } + }, [isEditing, setIsEditing, updateReason.doc, updateReason.selection]); + + useEffect(() => { + setHref(url); + }, [url]); + + const submitHref = useCallback(() => { + setIsEditing(false); + const range = linkShortcut ?? undefined; + + if (href === "") { + chain.removeLink(); + } else { + chain.updateLink({ href, auto: false }, range); + } + + chain.focus(range?.to ?? to).run(); + }, [setIsEditing, linkShortcut, chain, href, to]); + + const cancelHref = useCallback(() => { + setIsEditing(false); + }, [setIsEditing]); + + const clickEdit = useCallback(() => { + if (empty) { + chain.selectLink(); + } + + setIsEditing(true); + }, [chain, empty, setIsEditing]); + + return React.useMemo( + () => ({ + href, + setHref, + linkShortcut, + linkPositioner, + isEditing, + clickEdit, + onRemove, + submitHref, + cancelHref, + }), + [ + href, + linkShortcut, + linkPositioner, + isEditing, + clickEdit, + onRemove, + submitHref, + cancelHref, + ] + ); +} + +const DelayAutoFocusInput = ({ + autoFocus, + ...rest +}: React.HTMLProps) => { + const inputRef = useRef(null); + + useEffect(() => { + if (!autoFocus) { + return; + } + + const frame = window.requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + + return () => { + window.cancelAnimationFrame(frame); + }; + }, [autoFocus]); + + return ; +}; + +const FloatingLinkToolbar = ({ children }) => { + const { + isEditing, + linkPositioner, + clickEdit, + onRemove, + submitHref, + href, + setHref, + cancelHref, + } = useFloatingLinkState(); + const active = useActive(); + const activeLink = active.link(); + const { empty } = useCurrentSelection(); + + const handleClickEdit = useCallback(() => { + clickEdit(); + }, [clickEdit]); + + const linkEditButtons = activeLink ? ( + + + + + ) : ( + + + + ); + + return ( + <> + {!isEditing && ( + + {linkEditButtons} + {children} + + )} + {!isEditing && empty && ( + + {linkEditButtons} + {children} + + )} + + + ) => + setHref(event.target.value) + } + value={href} + onKeyPress={(event: React.KeyboardEvent) => { + const { code } = event; + + if (code === "Enter") { + submitHref(); + } + + if (code === "Escape") { + cancelHref(); + } + }} + /> + + + ); +}; + export interface SetHighlightButtonProps extends Omit< CommandButtonProps, @@ -132,6 +376,92 @@ export const SetHighlightButton: React.FC< ); }; +/** + * Build a slash command using Remirror's MentionExtension. + */ + +function UserSuggestor({ + allUsers, +}: { + allUsers: MentionExtensionAttributes[]; +}): JSX.Element { + const [users, setUsers] = useState([]); + const { createTable } = useCommands(); + const { state, getMenuProps, getItemProps, indexIsHovered, indexIsSelected } = + useMention({ + items: users, + + onExit: (props, command) => { + console.log("props", props); + // console.log("command", command); + // get the command + const { query } = props; + const { full } = query; + switch (full) { + case "table": + // insert table + console.log("Inserting table .."); + createTable({ + rowsCount: 3, + columnsCount: 3, + withHeaderRow: false, + }); + break; + default: + break; + } + }, + }); + + useEffect(() => { + if (!state) { + return; + } + + const searchTerm = state.query.full.toLowerCase(); + const filteredUsers = allUsers + .filter((user) => user.label.toLowerCase().includes(searchTerm)) + .sort() + .slice(0, 5); + setUsers(filteredUsers); + }, [state, allUsers]); + + const enabled = !!state; + + return ( + +
+ {enabled && + users.map((user, index) => { + const isHighlighted = indexIsSelected(index); + const isHovered = indexIsHovered(index); + + return ( +
+ {user.label} +
+ ); + })} +
+
+ ); +} + const MyStyledWrapper = styled("div")( () => ` .remirror-editor-wrapper { @@ -150,6 +480,7 @@ const MyEditor = ({ initialContent?: string; id: string; }) => { + // FIXME this is re-rendered all the time. const store = useContext(RepoContext); if (!store) throw new Error("Missing BearContext.Provider in the tree"); const setPodContent = useStore(store, (state) => state.setPodContent); @@ -159,6 +490,9 @@ const MyEditor = ({ const pod = getPod(id); const isGuest = useStore(store, (state) => state.role === "GUEST"); const setPodFocus = useStore(store, (state) => state.setPodFocus); + // the Yjs extension for Remirror + const provider = useStore(store, (state) => state.provider)!; + const setPodBlur = useStore(store, (state) => state.setPodBlur); const resetSelection = useStore(store, (state) => state.resetSelection); const updateView = useStore(store, (state) => state.updateView); @@ -174,6 +508,16 @@ const MyEditor = ({ new TextHighlightExtension(), new SupExtension(), new SubExtension(), + new LinkExtension({ autoLink: true }), + new ImageExtension({ enableResizing: true }), + new DropCursorExtension(), + new MyYjsExtension({ getProvider: () => provider, id }), + new MentionExtension({ + extraAttributes: { type: "user" }, + matchers: [ + { name: "slash", char: "/", appendText: " ", matchOffset: 0 }, + ], + }), // new CalloutExtension({ defaultType: "warn" }), ...wysiwygPreset(), ], @@ -210,62 +554,114 @@ const MyEditor = ({ ref={ref} overflow="auto" > - - - - { - let nextState = parameter.state; - setState(nextState); - // TODO sync with DB and yjs - if (parameter.tr?.docChanged) { - setPodContent({ id, content: nextState.doc.toJSON() }); - } - }} - > - {/* */} - - - - {!isGuest && ( - - - {/* */} - {/* */} - - {/* */} - - - - - - - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - - )} - {!isGuest && ( - - - - )} - {/* */} - - - - + + + { + let nextState = parameter.state; + setState(nextState); + // TODO sync with DB and yjs + if (parameter.tr?.docChanged) { + setPodContent({ id, content: nextState.doc.toJSON() }); + } + }} + > + {/* */} + + + + + + {!isGuest && ( + + + + + {/* */} + + {/* */} + + + + + + + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + + )} + + {/* */} + + + ); }; +function MyFloatingToolbar({ id }: { id: string }) { + const store = useContext(RepoContext); + if (!store) throw new Error("Missing BearContext.Provider in the tree"); + const reactFlowInstance = useReactFlow(); + // const selected = useStore(store, (state) => state.pods[id]?.selected); + const isGuest = useStore(store, (state) => state.role === "GUEST"); + return ( + <> + + + + {!isGuest && ( + + { + reactFlowInstance.deleteElements({ nodes: [{ id }] }); + }} + > + + + + )} + + + + + ); +} + /** * The React Flow node. */ @@ -304,28 +700,37 @@ export const RichNode = memo(function ({ const updateView = useStore(store, (state) => state.updateView); const reactFlowInstance = useReactFlow(); - const onResize = useCallback( + const [showToolbar, setShowToolbar] = useState(false); + const autoLayoutROOT = useStore(store, (state) => state.autoLayoutROOT); + + const onResizeStop = useCallback( (e, data) => { const { size } = data; const node = nodesMap.get(id); if (node) { - node.style = { ...node.style, width: size.width }; - nodesMap.set(id, node); + // new width + nodesMap.set(id, { + ...node, + width: size.width, + style: { ...node.style, width: size.width }, + }); setPodGeo( id, { parent: node.parentNode ? node.parentNode : "ROOT", x: node.position.x, y: node.position.y, + // new width width: size.width!, height: node.height!, }, true ); updateView(); + autoLayoutROOT(); } }, - [id, nodesMap, setPodGeo, updateView] + [id, nodesMap, setPodGeo, updateView, autoLayoutROOT] ); useEffect(() => { @@ -336,148 +741,284 @@ export const RichNode = memo(function ({ } }, [data.name, setPodName, id]); + const zoomLevel = useReactFlowStore((s) => s.transform[2]); + const contextualZoom = useStore(store, (state) => state.contextualZoom); + const level2fontsize = useStore(store, (state) => state.level2fontsize); + const threshold = useStore( + store, + (state) => state.contextualZoomParams.threshold + ); + if (!pod) return null; + const node = nodesMap.get(id); + + const fontSize = level2fontsize(node?.data.level); + const parentFontSize = level2fontsize(node?.data.level - 1); + + if ( + contextualZoom && + node?.data.level > 0 && + parentFontSize * zoomLevel < threshold + ) { + // The parent scope is not shown, this node is not gonna be rendered at all. + return ; + } + + if (contextualZoom && fontSize * zoomLevel < threshold) { + // Return a collapsed block. + let text = ""; + if (pod.content) { + // let json = JSON.parse(pod.content); + const plain = prosemirrorToPlainText(pod.content); + text = plain.split("\n")[0]; + } + text = text || "Empty"; + return ( + + + {text} + + + ); + } + // onsize is banned for a guest, FIXME: ugly code const Wrap = (child) => isGuest ? ( <>{child} ) : ( - - {child} - - ); - - return Wrap( - - - - - - - {devMode && ( + - {id} at ({Math.round(xPos)}, {Math.round(yPos)}, w: {pod.width}, h:{" "} - {pod.height}) + {child} - )} - - + + ); - top: "-24px", - width: "50%", - }} - > - { - 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", - }, + return ( + <> + { + setShowToolbar(true); + }} + onMouseLeave={() => { + setShowToolbar(false); + }} + sx={{ + cursor: "auto", + fontSize, + }} + > + {" "} + {Wrap( + - - - {!isGuest && ( - - { - reactFlowInstance.deleteElements({ nodes: [{ id }] }); + > + + + + + + + + + {devMode && ( + + {id} at ({Math.round(xPos)}, {Math.round(yPos)}, w:{" "} + {pod.width}, h: {pod.height}) + + )} + - - - - )} - - - - + { + 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", + }, + }} + > + + + + + + + + + + )} - + ); }); + +function prosemirrorToPlainText(prosemirrorJson) { + let plainText = ""; + + // Iterate through each node in the prosemirror JSON object + prosemirrorJson.content.forEach((node) => { + // Handle each node type + switch (node.type) { + // Handle paragraph nodes + case "paragraph": { + // Iterate through each child of the paragraph + if (node.content) { + node.content.forEach((child) => { + // If the child is text, add its value to the plainText string + if (child.type === "text") { + plainText += child.text; + } + }); + // Add a newline character after the paragraph + plainText += "\n"; + } + break; + } + // Handle heading nodes + case "heading": { + // Add the heading text to the plainText string + node.content.forEach((child) => { + // If the child is text, add its value to the plainText string + if (child.type === "text") { + plainText += child.text; + } + }); + // Add two newline characters after the heading + plainText += "\n\n"; + break; + } + // Handle other node types + default: { + // If the node has content, recursively call the function on its content + if (node.content) { + plainText += prosemirrorToPlainText(node); + } + break; + } + } + }); + + return plainText; +} diff --git a/ui/src/components/nodes/Scope.tsx b/ui/src/components/nodes/Scope.tsx index 15d7e2f4..e1196b28 100644 --- a/ui/src/components/nodes/Scope.tsx +++ b/ui/src/components/nodes/Scope.tsx @@ -21,6 +21,7 @@ import ReactFlow, { MarkerType, Node, NodeProps, + useStore as useReactFlowStore, } from "reactflow"; import "reactflow/dist/style.css"; @@ -33,50 +34,32 @@ import Grid from "@mui/material/Grid"; import DeleteIcon from "@mui/icons-material/Delete"; import ContentCutIcon from "@mui/icons-material/ContentCut"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline"; +import ViewTimelineOutlinedIcon from "@mui/icons-material/ViewTimelineOutlined"; +import CompressIcon from "@mui/icons-material/Compress"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; + import Moveable from "react-moveable"; import { useStore } from "zustand"; import { RepoContext } from "../../lib/store"; -import { NodeResizer, NodeResizeControl } from "@reactflow/node-resizer"; +import { NodeResizer, NodeResizeControl } from "reactflow"; import "@reactflow/node-resizer/dist/style.css"; import { ResizableBox } from "react-resizable"; import { ResizeIcon } from "./utils"; import { CopyToClipboard } from "react-copy-to-clipboard"; -export const ScopeNode = memo(function ScopeNode({ - data, - id, - isConnectable, - selected, - xPos, - yPos, -}) { - // add resize to the node - const ref = useRef(null); +function MyFloatingToolbar({ id }: { id: string }) { 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 nodesMap = useStore(store, (state) => state.ydoc.getMap("pods")); // const selected = useStore(store, (state) => state.pods[id]?.selected); const isGuest = useStore(store, (state) => state.role === "GUEST"); - const inputRef = useRef(null); + const wsRunScope = useStore(store, (state) => state.wsRunScope); const clonePod = useStore(store, (state) => state.clonePod); - const devMode = useStore(store, (state) => state.devMode); - const isCutting = useStore(store, (state) => state.cuttingIds.has(id)); - const setPaneFocus = useStore(store, (state) => state.setPaneFocus); - - useEffect(() => { - if (!data.name) return; - setPodName({ id, name: data.name || "" }); - if (inputRef?.current) { - inputRef.current.value = data.name; - } - }, [data.name, id, setPodName]); - const onCopy = useCallback( (clipboardData: any) => { const pod = clonePod(id); @@ -103,6 +86,187 @@ export const ScopeNode = memo(function ScopeNode({ }, [onCopy, cutBegin, id] ); + const autoLayout = useStore(store, (state) => state.autoLayout); + return ( + + + + + {!isGuest && ( + + { + wsRunScope(id); + }} + > + + + + )} + {/* auto force layout */} + {!isGuest && ( + + { + autoLayout(id); + }} + > + + + + )} + {/* copy to clipbooard */} + + + + + + + + + {!isGuest && ( + + + + + + + + )} + {!isGuest && ( + + { + // 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 }] }); + }} + > + + + + )} + + + + + ); +} + +export 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 setPodName = useStore(store, (state) => state.setPodName); + const nodesMap = useStore(store, (state) => state.ydoc.getMap("pods")); + // const selected = useStore(store, (state) => state.pods[id]?.selected); + const isGuest = useStore(store, (state) => state.role === "GUEST"); + const inputRef = useRef(null); + const getPod = useStore(store, (state) => state.getPod); + const pod = getPod(id); + + const devMode = useStore(store, (state) => state.devMode); + const isCutting = useStore(store, (state) => state.cuttingIds.has(id)); + + useEffect(() => { + if (!data.name) return; + setPodName({ id, name: data.name || "" }); + if (inputRef?.current) { + inputRef.current.value = data.name; + } + }, [data.name, id, setPodName]); + + const [showToolbar, setShowToolbar] = useState(false); + + const contextualZoom = useStore(store, (state) => state.contextualZoom); + const threshold = useStore( + store, + (state) => state.contextualZoomParams.threshold + ); + const zoomLevel = useReactFlowStore((s) => s.transform[2]); + const node = nodesMap.get(id); + const level2fontsize = useStore(store, (state) => state.level2fontsize); + const fontSize = level2fontsize(node?.data.level); + const parentFontSize = level2fontsize(node?.data.level - 1); + + if ( + contextualZoom && + node?.data.level > 0 && + parentFontSize * zoomLevel < threshold + ) { + // The parent scope is not shown, this node is not gonna be rendered at all. + return ; + } + + if (contextualZoom && fontSize * zoomLevel < threshold) { + // Return a collapsed blcok. + let text = node?.data.name ? `${node?.data.name}` : "A Scope"; + return ( + + + {text} + + + ); + } return ( (function ScopeNode({ height: "100%", border: isCutting ? "dashed 2px red" : "solid 1px #d6dee6", borderRadius: "4px", + cursor: "auto", + fontSize, + }} + onMouseEnter={() => { + setShowToolbar(true); + }} + onMouseLeave={() => { + setShowToolbar(false); }} - className="custom-drag-handle" > {/* */} - - - + + + + + + (function ScopeNode({ justifyContent: "center", }} > - - - - - - - - - {!isGuest && ( - - - - - - - - )} - {!isGuest && ( - - { - // 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. */} (function ScopeNode({ userSelect: "text", cursor: "auto", }} - className="nodrag" > - {id} at ({xPos}, {yPos}), level: {data.level} + {id} at ({xPos}, {yPos}), w: {pod.width}, h: {pod.height} parent:{" "} + {pod.parent} level: {data.level} fontSize: {fontSize} )} @@ -220,6 +376,7 @@ export const ScopeNode = memo(function ScopeNode({ display: "flex", flexGrow: 1, justifyContent: "center", + fontSize, }} > (function ScopeNode({ padding: "0px", textAlign: "center", textOverflow: "ellipsis", + fontSize, }, }} > @@ -252,24 +410,6 @@ export const ScopeNode = memo(function ScopeNode({ - - - ); }); diff --git a/ui/src/components/nodes/YjsRemirror.tsx b/ui/src/components/nodes/YjsRemirror.tsx new file mode 100644 index 00000000..434beb0b --- /dev/null +++ b/ui/src/components/nodes/YjsRemirror.tsx @@ -0,0 +1,352 @@ +// Adapted from https://github.com/remirror/remirror/blob/main/packages/remirror__extension-yjs/src/yjs-extension.ts +// Added node ID to bind a different room for each rich-text instance. + +import { + defaultCursorBuilder, + defaultDeleteFilter, + defaultSelectionBuilder, + redo, + undo, + yCursorPlugin, + ySyncPlugin, + ySyncPluginKey, + yUndoPlugin, + yUndoPluginKey, +} from "y-prosemirror"; +import type { Doc } from "yjs"; +import { UndoManager } from "yjs"; +import { + AcceptUndefined, + command, + convertCommand, + EditorState, + ErrorConstant, + extension, + ExtensionPriority, + invariant, + isEmptyObject, + isFunction, + keyBinding, + KeyBindingProps, + NamedShortcut, + nonChainable, + NonChainableCommandFunction, + OnSetOptionsProps, + PlainExtension, + ProsemirrorPlugin, + Selection, + Shape, + Static, +} from "@remirror/core"; +import { ExtensionHistoryMessages as Messages } from "@remirror/messages"; +import { DecorationAttrs } from "@remirror/pm/view"; + +export interface ColorDef { + light: string; + dark: string; +} + +export interface YSyncOpts { + colors?: ColorDef[]; + colorMapping?: Map; + permanentUserData?: any | null; +} + +/** + * yjs typings are very rough; so we define here the interface that we require + * (y-webrtc and y-websocket providers are both compatible with this interface; + * no other providers have been checked). + */ +interface YjsRealtimeProvider { + doc: Doc; + awareness: any; + destroy: () => void; + disconnect: () => void; +} + +export interface YjsOptions< + Provider extends YjsRealtimeProvider = YjsRealtimeProvider +> { + id: string; + /** + * Get the provider for this extension. + */ + getProvider: Provider | (() => Provider); + + /** + * Remove the active provider. This should only be set at initial construction + * of the editor. + */ + destroyProvider?: (provider: Provider) => void; + + /** + * The options which are passed through to the Yjs sync plugin. + */ + syncPluginOptions?: AcceptUndefined; + + /** + * Take the user data and transform it into a html element which is used for + * the cursor. This is passed into the cursor builder. + * + * See https://github.com/yjs/y-prosemirror#remote-cursors + */ + cursorBuilder?: (user: Shape) => HTMLElement; + + /** + * Generator for the selection attributes + */ + selectionBuilder?: (user: Shape) => DecorationAttrs; + + /** + * By default all editor bindings use the awareness 'cursor' field to + * propagate cursor information. + * + * @defaultValue 'cursor' + */ + cursorStateField?: string; + + /** + * Get the current editor selection. + * + * @defaultValue `(state) => state.selection` + */ + getSelection?: (state: EditorState) => Selection; + + disableUndo?: Static; + + /** + * Names of nodes in the editor which should be protected. + * + * @defaultValue `new Set('paragraph')` + */ + protectedNodes?: Static>; + trackedOrigins?: Static; +} + +/** + * The YJS extension is the recommended extension for creating a collaborative + * editor. + */ +@extension({ + defaultOptions: { + id: "defaultId", + getProvider: (): never => { + invariant(false, { + code: ErrorConstant.EXTENSION, + message: "You must provide a YJS Provider to the `YjsExtension`.", + }); + }, + destroyProvider: defaultDestroyProvider, + syncPluginOptions: undefined, + cursorBuilder: defaultCursorBuilder, + selectionBuilder: defaultSelectionBuilder, + cursorStateField: "cursor", + getSelection: (state) => state.selection, + disableUndo: false, + protectedNodes: new Set("paragraph"), + trackedOrigins: [], + }, + staticKeys: ["disableUndo", "protectedNodes", "trackedOrigins"], + defaultPriority: ExtensionPriority.High, +}) +export class MyYjsExtension extends PlainExtension { + get name() { + return "yjs" as const; + } + + private _provider?: YjsRealtimeProvider; + + /** + * The provider that is being used for the editor. + */ + get provider(): YjsRealtimeProvider { + const { getProvider } = this.options; + + return (this._provider ??= getLazyValue(getProvider)); + } + + getBinding(): { mapping: Map } | undefined { + const state = this.store.getState(); + const { binding } = ySyncPluginKey.getState(state); + return binding; + } + + /** + * Create the yjs plugins. + */ + createExternalPlugins(): ProsemirrorPlugin[] { + const { + syncPluginOptions, + cursorBuilder, + getSelection, + cursorStateField, + disableUndo, + protectedNodes, + trackedOrigins, + selectionBuilder, + } = this.options; + + const yDoc = this.provider.doc; + const id = this.options.id; + const type = yDoc.getXmlFragment("rich-" + id); + + const plugins = [ + ySyncPlugin(type, syncPluginOptions), + yCursorPlugin( + this.provider.awareness, + { cursorBuilder, getSelection, selectionBuilder }, + cursorStateField + ), + ]; + + if (!disableUndo) { + const undoManager = new UndoManager(type, { + trackedOrigins: new Set([ySyncPluginKey, ...trackedOrigins]), + deleteFilter: (item) => defaultDeleteFilter(item, protectedNodes), + }); + plugins.push(yUndoPlugin({ undoManager })); + } + + return plugins; + } + + /** + * This managers the updates of the collaboration provider. + */ + onSetOptions(props: OnSetOptionsProps): void { + const { changes, pickChanged } = props; + const changedPluginOptions = pickChanged([ + "cursorBuilder", + "cursorStateField", + "getProvider", + "getSelection", + "syncPluginOptions", + ]); + + if (changes.getProvider.changed) { + this._provider = undefined; + const previousProvider = getLazyValue(changes.getProvider.previousValue); + + // Check whether the values have changed. + if (changes.destroyProvider.changed) { + changes.destroyProvider.previousValue?.(previousProvider); + } else { + this.options.destroyProvider(previousProvider); + } + } + + if (!isEmptyObject(changedPluginOptions)) { + this.store.updateExtensionPlugins(this); + } + } + + /** + * Remove the provider from the manager. + */ + onDestroy(): void { + if (!this._provider) { + return; + } + + this.options.destroyProvider(this._provider); + this._provider = undefined; + } + + /** + * Undo that last Yjs transaction(s) + * + * This command does **not** support chaining. + * This command is a no-op and always returns `false` when the `disableUndo` option is set. + */ + @command({ + disableChaining: true, + description: ({ t }) => t(Messages.UNDO_DESCRIPTION), + label: ({ t }) => t(Messages.UNDO_LABEL), + icon: "arrowGoBackFill", + }) + yUndo(): NonChainableCommandFunction { + return nonChainable((props) => { + if (this.options.disableUndo) { + return false; + } + + const { state, dispatch } = props; + const undoManager: UndoManager = + yUndoPluginKey.getState(state).undoManager; + + if (undoManager.undoStack.length === 0) { + return false; + } + + if (!dispatch) { + return true; + } + + return convertCommand(undo)(props); + }); + } + + /** + * Redo the last transaction undone with a previous `yUndo` command. + * + * This command does **not** support chaining. + * This command is a no-op and always returns `false` when the `disableUndo` option is set. + */ + @command({ + disableChaining: true, + description: ({ t }) => t(Messages.REDO_DESCRIPTION), + label: ({ t }) => t(Messages.REDO_LABEL), + icon: "arrowGoForwardFill", + }) + yRedo(): NonChainableCommandFunction { + return nonChainable((props) => { + if (this.options.disableUndo) { + return false; + } + + const { state, dispatch } = props; + const undoManager: UndoManager = + yUndoPluginKey.getState(state).undoManager; + + if (undoManager.redoStack.length === 0) { + return false; + } + + if (!dispatch) { + return true; + } + + return convertCommand(redo)(props); + }); + } + + /** + * Handle the undo keybinding. + */ + @keyBinding({ shortcut: NamedShortcut.Undo, command: "yUndo" }) + undoShortcut(props: KeyBindingProps): boolean { + return this.yUndo()(props); + } + + /** + * Handle the redo keybinding for the editor. + */ + @keyBinding({ shortcut: NamedShortcut.Redo, command: "yRedo" }) + redoShortcut(props: KeyBindingProps): boolean { + return this.yRedo()(props); + } +} + +/** + * The default destroy provider method. + */ +export function defaultDestroyProvider(provider: YjsRealtimeProvider): void { + const { doc } = provider; + provider.disconnect(); + provider.destroy(); + doc.destroy(); +} + +function getLazyValue(lazyValue: Type | (() => Type)): Type { + return isFunction(lazyValue) ? lazyValue() : lazyValue; +} diff --git a/ui/src/components/nodes/remirror-size.css b/ui/src/components/nodes/remirror-size.css new file mode 100644 index 00000000..d6840421 --- /dev/null +++ b/ui/src/components/nodes/remirror-size.css @@ -0,0 +1,95 @@ + +.remirror-theme h1 { + font-size: 1.5em; + font-weight: 600; + margin: 0.5em 0; + } + + .remirror-theme h2 { + font-size: 1.25em; + font-weight: 600; + margin: 0.5em 0; + } + + .remirror-theme h3 { + font-size: 1.1em; + font-weight: 600; + margin: 0.5em 0; + } + + .remirror-theme h4 { + font-size: 1em; + font-weight: 600; + margin: 0.5em 0; + } + + + .remirror-theme h5 { + font-size: 0.9em; + font-weight: 600; + margin: 0.5em 0; + } + + + .remirror-theme h6 { + font-size: 0.8em; + font-weight: 600; + margin: 0.5em 0; + } + + .remirror-theme p { + margin: 0.5em 0; + } + + .remirror-theme ul { + margin: 0.5em 0; + } + + .remirror-theme ol { + margin: 0.5em 0; + } + + .remirror-theme li { + margin: 0.5em 0; + } + + .remirror-theme blockquote { + margin: 0.5em 0; + } + + .remirror-theme pre { + margin: 0.5em 0; + } + + + .remirror-theme code { + font-family: monospace; + font-size: 0.9em; + background: #f5f5f5; + padding: 0.1em 0.2em; + border-radius: 4px; + } + + + .remirror-theme hr { + border: none; + border-top: 1px solid #ddd; + margin: 0.5em 0; + } + + + .remirror-theme table { + border-collapse: collapse; + border-spacing: 0; + margin: 0.5em 0; + } + + + .remirror-theme table td, + .remirror-theme table th { + border: 1px solid #ddd; + padding: 0.5em 1em; + } + + + \ No newline at end of file diff --git a/ui/src/components/nodes/utils.tsx b/ui/src/components/nodes/utils.tsx index 15cfac5c..a49ce791 100644 --- a/ui/src/components/nodes/utils.tsx +++ b/ui/src/components/nodes/utils.tsx @@ -1,4 +1,18 @@ -import { Position, internalsSymbol } from "reactflow"; +import { + Position, + internalsSymbol, + Node, + NodePositionChange, + XYPosition, +} from "reactflow"; + +import { useContext } from "react"; + +import Button from "@mui/material/Button"; + +import { useStore } from "zustand"; + +import { RepoContext } from "../../lib/store"; export function ResizeIcon() { return ( @@ -99,3 +113,330 @@ export function getEdgeParams(source, target) { targetPos, }; } + +type GetHelperLinesResult = { + horizontal?: number; + vertical?: number; + snapPosition: Partial; +}; + +// this utility function can be called with a position change (inside onNodesChange) +// it checks all other nodes and calculated the helper line positions and the position where the current node should snap to +export function getHelperLines( + change: NodePositionChange, + nodes: Node[], + distance = 5 +): GetHelperLinesResult { + const defaultResult = { + horizontal: undefined, + vertical: undefined, + snapPosition: { x: undefined, y: undefined }, + }; + const nodeA = nodes.find((node) => node.id === change.id); + + if (!nodeA || !change.position) { + return defaultResult; + } + + const nodeABounds = { + left: change.position.x, + right: change.position.x + (nodeA.width ?? 0), + top: change.position.y, + bottom: change.position.y + (nodeA.height ?? 0), + width: nodeA.width ?? 0, + height: nodeA.height ?? 0, + }; + + let horizontalDistance = distance; + let verticalDistance = distance; + + return nodes + .filter((node) => node.id !== nodeA.id) + .reduce((result, nodeB) => { + const nodeBBounds = { + left: nodeB.position.x, + right: nodeB.position.x + (nodeB.width ?? 0), + top: nodeB.position.y, + bottom: nodeB.position.y + (nodeB.height ?? 0), + width: nodeB.width ?? 0, + height: nodeB.height ?? 0, + }; + + // |‾‾‾‾‾‾‾‾‾‾‾| + // | A | + // |___________| + // | + // | + // |‾‾‾‾‾‾‾‾‾‾‾| + // | B | + // |___________| + const distanceLeftLeft = Math.abs(nodeABounds.left - nodeBBounds.left); + + if (distanceLeftLeft < verticalDistance) { + result.snapPosition.x = nodeBBounds.left; + result.vertical = nodeBBounds.left; + verticalDistance = distanceLeftLeft; + } + + // |‾‾‾‾‾‾‾‾‾‾‾| + // | A | + // |___________| + // | + // | + // |‾‾‾‾‾‾‾‾‾‾‾| + // | B | + // |___________| + const distanceRightRight = Math.abs( + nodeABounds.right - nodeBBounds.right + ); + + if (distanceRightRight < verticalDistance) { + result.snapPosition.x = nodeBBounds.right - nodeABounds.width; + result.vertical = nodeBBounds.right; + verticalDistance = distanceRightRight; + } + + // |‾‾‾‾‾‾‾‾‾‾‾| + // | A | + // |___________| + // | + // | + // |‾‾‾‾‾‾‾‾‾‾‾| + // | B | + // |___________| + const distanceLeftRight = Math.abs(nodeABounds.left - nodeBBounds.right); + + if (distanceLeftRight < verticalDistance) { + result.snapPosition.x = nodeBBounds.right; + result.vertical = nodeBBounds.right; + verticalDistance = distanceLeftRight; + } + + // |‾‾‾‾‾‾‾‾‾‾‾| + // | A | + // |___________| + // | + // | + // |‾‾‾‾‾‾‾‾‾‾‾| + // | B | + // |___________| + const distanceRightLeft = Math.abs(nodeABounds.right - nodeBBounds.left); + + if (distanceRightLeft < verticalDistance) { + result.snapPosition.x = nodeBBounds.left - nodeABounds.width; + result.vertical = nodeBBounds.left; + verticalDistance = distanceRightLeft; + } + + // |‾‾‾‾‾‾‾‾‾‾‾|‾‾‾‾‾|‾‾‾‾‾‾‾‾‾‾‾| + // | A | | B | + // |___________| |___________| + const distanceTopTop = Math.abs(nodeABounds.top - nodeBBounds.top); + + if (distanceTopTop < horizontalDistance) { + result.snapPosition.y = nodeBBounds.top; + result.horizontal = nodeBBounds.top; + horizontalDistance = distanceTopTop; + } + + // |‾‾‾‾‾‾‾‾‾‾‾| + // | A | + // |___________|_________________ + // | | + // | B | + // |___________| + const distanceBottomTop = Math.abs(nodeABounds.bottom - nodeBBounds.top); + + if (distanceBottomTop < horizontalDistance) { + result.snapPosition.y = nodeBBounds.top - nodeABounds.height; + result.horizontal = nodeBBounds.top; + horizontalDistance = distanceBottomTop; + } + + // |‾‾‾‾‾‾‾‾‾‾‾| |‾‾‾‾‾‾‾‾‾‾‾| + // | A | | B | + // |___________|_____|___________| + const distanceBottomBottom = Math.abs( + nodeABounds.bottom - nodeBBounds.bottom + ); + + if (distanceBottomBottom < horizontalDistance) { + result.snapPosition.y = nodeBBounds.bottom - nodeABounds.height; + result.horizontal = nodeBBounds.bottom; + horizontalDistance = distanceBottomBottom; + } + + // |‾‾‾‾‾‾‾‾‾‾‾| + // | B | + // | | + // |‾‾‾‾‾‾‾‾‾‾‾|‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ + // | A | + // |___________| + const distanceTopBottom = Math.abs(nodeABounds.top - nodeBBounds.bottom); + + if (distanceTopBottom < horizontalDistance) { + result.snapPosition.y = nodeBBounds.bottom; + result.horizontal = nodeBBounds.bottom; + horizontalDistance = distanceTopBottom; + } + + return result; + }, defaultResult); +} + +export function NewPodButtons({ pod, xPos, yPos }) { + const store = useContext(RepoContext); + if (!store) throw new Error("Missing BearContext.Provider in the tree"); + const addNode = useStore(store, (state) => state.addNode); + return ( + <> + {/* Bottom 1 */} + + + {/* Bottom 2 */} + + {/* Left 1 */} + + + {/* Left 2 */} + + + {/* Right 1 */} + + + {/* Right 2 */} + + + ); +} diff --git a/ui/src/lib/auth.tsx b/ui/src/lib/auth.tsx index 7c1c5a17..8b0c8232 100644 --- a/ui/src/lib/auth.tsx +++ b/ui/src/lib/auth.tsx @@ -6,6 +6,7 @@ import { HttpLink, gql, } from "@apollo/client"; +import jwt_decode from "jwt-decode"; type AuthContextType = ReturnType; @@ -32,11 +33,15 @@ function useProvideAuth() { useEffect(() => { // load initial state from local storage - setAuthToken(localStorage.getItem("token") || null); + setAuthToken( + localStorage.getItem("token") || + localStorage.getItem("guestToken") || + null + ); }, []); - const getAuthHeaders = () => { - if (!authToken) return null; + const getAuthHeaders = (): Record => { + if (!authToken) return {}; return { authorization: `Bearer ${authToken}`, @@ -57,10 +62,10 @@ function useProvideAuth() { const signOut = () => { console.log("sign out"); - setAuthToken(null); // HEBI CAUTION this must be removed. Otherwise, when getItem back, it is not null, but "null" // localStorage.setItem("token", null); localStorage.removeItem("token"); + setAuthToken(localStorage.getItem("guestToken") || null); }; const handleGoogle = async (response) => { @@ -86,6 +91,68 @@ function useProvideAuth() { } }; + let guestSigningUp = false; + + const loginGuest = async () => { + console.log("Loginning as guest."); + // If there is a guest token, decode the guest ID from it, and login with the guest ID + let token = localStorage.getItem("guestToken"); + if (token) { + console.log("Guest token found, logining in .."); + const { id } = jwt_decode(token) as { id: string }; + // login a guest user with the guest ID + const client = createApolloClient(); + const LoginGuestMutation = gql` + mutation LoginGuestMutation($id: String!) { + loginGuest(id: $id) { + token + } + } + `; + const result = await client.mutate({ + mutation: LoginGuestMutation, + variables: { id }, + }); + if (result?.data?.loginGuest?.token) { + const token = result.data.loginGuest.token; + setAuthToken(token); + localStorage.setItem("guestToken", token); + } + } else { + // Signup a guest user + console.log("Guest token not found, signing up .."); + // set a 5 seconds timeout so that no duplicate guest users are created + if (guestSigningUp) { + console.log("Guest signing up, waiting .."); + return; + } + guestSigningUp = true; + setTimeout(() => { + guestSigningUp = false; + }, 5000); + + // actually signup the user + const client = createApolloClient(); + const SignupGuestMutation = gql` + mutation SignupGuestMutation { + signupGuest { + token + } + } + `; + + const result = await client.mutate({ + mutation: SignupGuestMutation, + }); + + if (result?.data?.signupGuest?.token) { + const token = result.data.signupGuest.token; + setAuthToken(token); + localStorage.setItem("guestToken", token); + } + } + }; + const signIn = async ({ email, password }) => { const client = createApolloClient(); const LoginMutation = gql` @@ -153,7 +220,7 @@ function useProvideAuth() { * This is not immediately set onrefresh. */ const isSignedIn = () => { - if (authToken) { + if (authToken && localStorage.getItem("token") !== null) { return true; } else { return false; @@ -164,13 +231,17 @@ function useProvideAuth() { * This is set immediately on refresh. */ function hasToken() { - return localStorage.getItem("token") !== null; + return ( + localStorage.getItem("token") !== null || + localStorage.getItem("guestToken") !== null + ); } return { createApolloClient, signIn, signOut, + loginGuest, handleGoogle, signUp, isSignedIn, diff --git a/ui/src/lib/fetch.tsx b/ui/src/lib/fetch.tsx index 2f2e3ba5..f04faa8d 100644 --- a/ui/src/lib/fetch.tsx +++ b/ui/src/lib/fetch.tsx @@ -98,6 +98,37 @@ export async function doRemoteLoadRepo(client: ApolloClient, id: string) { } } +/** + * For historical reason, the backend DB schema pod.type are "CODE", "DECK", + * "WYSIWYG", while the node types in front-end are "CODE", "SCOPE", "RICH". + */ + +function dbtype2nodetype(dbtype: string) { + switch (dbtype) { + case "CODE": + return "CODE"; + case "DECK": + return "SCOPE"; + case "WYSIWYG": + return "RICH"; + default: + throw new Error(`unknown dbtype ${dbtype}`); + } +} + +function nodetype2dbtype(nodetype: string) { + switch (nodetype) { + case "CODE": + return "CODE"; + case "SCOPE": + return "DECK"; + case "RICH": + return "WYSIWYG"; + default: + throw new Error(`unknown nodetype ${nodetype}`); + } +} + export function normalize(pods) { const res: { [key: string]: Pod } = { ROOT: { @@ -108,7 +139,7 @@ export function normalize(pods) { // Adding this to avoid errors // XXX should I save these to db? lang: "python", - type: "DECK", + type: "SCOPE", content: "", x: 0, y: 0, @@ -141,29 +172,21 @@ export function normalize(pods) { type: res[id].type, })) : []; - // change children.id format - // UDPATE Or, I just put {id,type} in the children array - // - // pod.children = pod.children.map(({ id }) => id); - // - // sort according to index - // pod.children.sort((a, b) => res[a.id].index - res[b.id].index); - // if (pod.type === "WYSIWYG" || pod.type === "CODE") { - // pod.content = JSON.parse(pod.content); - // } pod.content = JSON.parse(pod.content); pod.staged = JSON.parse(pod.staged); pod.githead = JSON.parse(pod.githead); + pod.type = dbtype2nodetype(pod.type); if (pod.result) { pod.result = JSON.parse(pod.result); } if (pod.error) { pod.error = JSON.parse(pod.error); } + if (pod.stdout) pod.stdout = JSON.parse(pod.stdout); // DEBUG the deck's content seems to be a long string of escaped \ - if (pod.type === "DECK" && pod.content) { + if (pod.type === "SCOPE" && pod.content) { console.log( - `warning: deck ${pod.id} content is not null, setting to null:`, + `warning: SCOPE ${pod.id} content is not null, setting to null:`, pod.content ); pod.content = null; @@ -195,17 +218,17 @@ function serializePodInput(pod) { height, }) => ({ id, - type, + type: nodetype2dbtype(type), column, lang, - // stdout, fold, thundar, utility, name, content: JSON.stringify(content), - // result: JSON.stringify(result), - // error: JSON.stringify(error), + stdout: JSON.stringify(stdout), + result: JSON.stringify(result), + error: JSON.stringify(error), x, y, width, diff --git a/ui/src/lib/nodes.tsx b/ui/src/lib/nodes.tsx index 6f4d7bdd..a9e25f96 100644 --- a/ui/src/lib/nodes.tsx +++ b/ui/src/lib/nodes.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useState, useContext } from "react"; import { applyNodeChanges, Edge, Node } from "reactflow"; import { RepoContext } from "./store"; -import { nodetype2dbtype } from "./utils"; import { useStore } from "zustand"; import { useApolloClient } from "@apollo/client"; import { Transaction, YEvent } from "yjs"; @@ -16,6 +15,10 @@ export function useYjsObserver() { const nodesMap = ydoc.getMap("pods"); const updateView = useStore(store, (state) => state.updateView); const resetSelection = useStore(store, (state) => state.resetSelection); + const buildNode2Children = useStore( + store, + (state) => state.buildNode2Children + ); useEffect(() => { const observer = (YMapEvent: YEvent, transaction: Transaction) => { @@ -30,7 +33,7 @@ export function useYjsObserver() { id: node.id, children: [], parent: "ROOT", - type: nodetype2dbtype(node.type || ""), + type: node.type as "CODE" | "SCOPE" | "RICH", lang: "python", x: node.position.x, y: node.position.y, @@ -57,6 +60,11 @@ export function useYjsObserver() { }, false ); + // FIXME debug this. + // if the pod parent changed, triggger buildNode2Children + if (node.parentNode !== change.oldValue.parentNode) { + buildNode2Children(); + } break; default: diff --git a/ui/src/lib/parser.tsx b/ui/src/lib/parser.tsx index 8208b233..2d263ec5 100644 --- a/ui/src/lib/parser.tsx +++ b/ui/src/lib/parser.tsx @@ -34,7 +34,7 @@ export async function initParser(prefix = "/", callback = () => {}) { export type Annotation = { name: string; - type: string; + type: "function" | "callsite" | "vardef" | "varuse" | "bridge"; startIndex: number; endIndex: number; startPosition: { row: number; column: number }; @@ -43,11 +43,22 @@ export type Annotation = { origin?: string; }; +type CodeAnalysisResult = { + ispublic: boolean; + isbridge?: boolean; + annotations: Annotation[]; + errors?: string[]; + error_messages?: string[]; +}; + /** * Use tree-sitter query to analyze the code. This only work for functions. * @param code */ -export function analyzeCodeViaQuery(code) { +export function analyzeCodeViaQuery(code: string): CodeAnalysisResult { + console.warn( + "DEPRECATED: analyzeCodeViaQuery is deprecated. Enable scoped variables instead." + ); let annotations: Annotation[] = []; let ispublic = false; // FIXME better error handling @@ -76,7 +87,8 @@ export function analyzeCodeViaQuery(code) { let node = match.captures[0].node; annotations.push({ name: node.text, // the name of the function or variable - type: match.captures[0].name, // "function" or "variable" + // FIXME the name may not be "callsite". + type: match.captures[0].name as "function" | "callsite", startIndex: node.startIndex, endIndex: node.endIndex, startPosition: node.startPosition, @@ -96,11 +108,33 @@ export function analyzeCodeViaQuery(code) { * @param code the code * @returns a list of names defined in this code. */ -export function analyzeCode(code) { +export function analyzeCode(code: string): CodeAnalysisResult { let annotations: Annotation[] = []; let ispublic = false; // FIXME better error handling if (!code) return { ispublic, annotations }; + // check for @export statements, a regular expression that matches a starting + // of the line followed by @export and a name + + const re = /^@export +([a-zA-Z0-9_]+)$/gm; + code = code.replaceAll(re, (match, name, offset) => { + // parse the first name as a function definition + annotations.push({ + name, + type: "bridge", + // This should be the actual start and end index of the name. + startIndex: offset, + endIndex: offset + match.length, + // The row & column are placeholders. + startPosition: { row: 0, column: 0 }, + endPosition: { row: 0, column: 0 }, + }); + return " ".repeat(match.length); + }); + if (annotations.length > 0) { + return { ispublic: true, isbridge: true, annotations }; + } + if (code.trim().startsWith("@export")) { ispublic = true; code = code.replace("@export", " ".repeat("@export".length)); @@ -112,23 +146,27 @@ export function analyzeCode(code) { } let tree = parser.parse(code); - const function_def = "(function_definition (identifier) @function)"; + // Only match module-level functions, to avoid Class methods. + const function_def = "(module (function_definition (identifier) @function))"; + // Match module-level classes as well. Just record as a function. + const class_def = "(module (class_definition (identifier) @class))"; const vardef = ` - (expression_statement (assignment (identifier) @vardef)) - (expression_statement (assignment (pattern_list (identifier) @vardef))) + (module (expression_statement (assignment (identifier) @vardef))) + (module (expression_statement (assignment (pattern_list (identifier) @vardef)))) `; let query_func = parser.getLanguage().query(` [ ${function_def} + ${class_def} ] `); query_func.matches(tree.rootNode).forEach((match) => { let node = match.captures[0].node; annotations.push({ name: node.text, // the name of the function or variable - type: match.captures[0].name, // "function" or "variable" + type: "function", startIndex: node.startIndex, endIndex: node.endIndex, startPosition: node.startPosition, @@ -140,36 +178,28 @@ export function analyzeCode(code) { ${vardef} ] `); - // Do not parse variable def/use inside a function definition. - // FIXME there might be duplicate. - // let index2annotation: Record = {}; - // annotations = annotations.concat(Object.values(index2annotation)); - tree.rootNode.children - .filter(({ type }) => type !== "function_definition") - .forEach((child) => { - query_var.matches(child).forEach((match) => { - let node = match.captures[0].node; - let annotation = { - name: node.text, // the name of the function or variable - type: match.captures[0].name, // "function" or "variable" - startIndex: node.startIndex, - endIndex: node.endIndex, - startPosition: node.startPosition, - endPosition: node.endPosition, - }; - // index2annotation[node.startIndex] = annotation; - annotations.push(annotation); - }); + + query_var.matches(tree.rootNode).forEach((match) => { + let node = match.captures[0].node; + annotations.push({ + name: node.text, // the name of the function or variable + type: "vardef", + startIndex: node.startIndex, + endIndex: node.endIndex, + startPosition: node.startPosition, + endPosition: node.endPosition, }); + }); + // Do the compilation: unbound variable analysis. let { unbound, errors } = compileModule(tree.rootNode); if (errors.length > 0) { - console.log("ERROR in compileModule", errors); + console.warn("ERROR in compileModule", errors); } unbound .filter((node) => !keywords.has(node.text)) .forEach((node) => { - let annotation = { + let annotation: Annotation = { name: node.text, type: "varuse", startIndex: node.startIndex, @@ -239,6 +269,7 @@ let global_unbound: Parser.SyntaxNode[] = []; let global_errors: any[] = []; export function compileModule(node: Parser.SyntaxNode) { + if (!node) return { unbound: [], errors: [] }; global_unbound = []; global_errors = []; let st: Set = new Set(); @@ -267,7 +298,8 @@ export function compileModule(node: Parser.SyntaxNode) { } function compileForIn(node: Parser.SyntaxNode, st: any, named_expr) { - let [left, expr] = node.namedChildren; + if (!node) return new Set(); + let [left, expr] = node.namedChildren.filter(notComment); compileExpression(expr, st); st = union(st, compileLHS(left, st)); if (node.nextSibling?.type !== "for_in_clause") { @@ -279,12 +311,14 @@ function compileForIn(node: Parser.SyntaxNode, st: any, named_expr) { } function compileListComprehension(node: Parser.SyntaxNode, st: any) { + if (!node) return new Set(); let named_expr = node.namedChild(0); return compileForIn(node.namedChild(1)!, st, named_expr); } function compileForStatement(node: Parser.SyntaxNode, st: any) { - let [left, right, body] = node.namedChildren; + if (!node) return new Set(); + let [left, right, body] = node.namedChildren.filter(notComment); compileExpression(right, st); st = union(st, compileLHS(left, st)); compileBlock(body, st); @@ -292,7 +326,8 @@ function compileForStatement(node: Parser.SyntaxNode, st: any) { } function compileAsPattern(node: Parser.SyntaxNode, st: any) { - let [source, target] = node.namedChildren; + if (!node) return new Set(); + let [source, target] = node.namedChildren.filter(notComment); compileExpression(source, st); if (target) { return new Set([target.text]); @@ -301,6 +336,7 @@ function compileAsPattern(node: Parser.SyntaxNode, st: any) { } function compileExceptClause(node: Parser.SyntaxNode, st: any) { + if (!node) return new Set(); match(node.namedChildren.filter(notComment)) .with([{ type: "as_pattern" }, P._], ([pattern, body]) => { st = union(st, compileAsPattern(pattern, st)); @@ -324,7 +360,8 @@ function compileExceptClause(node: Parser.SyntaxNode, st: any) { } function compileTryStatement(node: Parser.SyntaxNode, st: any) { - let [tryClause, ...clauses] = node.namedChildren; + if (!node) return new Set(); + let [tryClause, ...clauses] = node.namedChildren.filter(notComment); compileBlock(tryClause, st); clauses.forEach((clause) => { switch (clause.type) { @@ -348,6 +385,7 @@ function compileTryStatement(node: Parser.SyntaxNode, st: any) { } function compileIfStatement(node: Parser.SyntaxNode, st: any) { + if (!node) return new Set(); // There's no way to get the children by field names. let cond = // node.childForFieldName("condition"); let alt = // node.childForFieldName("alternative"); @@ -383,20 +421,21 @@ function compileIfStatement(node: Parser.SyntaxNode, st: any) { * @returns */ function compileLHS(node, st) { + if (!node) return new Set(); // left hand side switch (node.type) { case "identifier": return new Set([node.text]); case "pattern_list": - return new Set(node.namedChildren.map((n) => n.text)); + return new Set(node.namedChildren.filter(notComment).map((n) => n.text)); case "tuple_pattern": - return new Set(node.namedChildren.map((n) => n.text)); + return new Set(node.namedChildren.filter(notComment).map((n) => n.text)); case "subscript": - let [l, r] = node.namedChildren; + let [l, r] = node.namedChildren.filter(notComment); compileExpression(r, st); return compileLHS(l, st); case "attribute": - let [obj, attr] = node.namedChildren; + let [obj, attr] = node.namedChildren.filter(notComment); return compileLHS(obj, st); default: global_errors.push({ @@ -408,25 +447,28 @@ function compileLHS(node, st) { } function compileAssignment(node: Parser.SyntaxNode, st: any) { - let [left, right] = node.namedChildren; + if (!node) return new Set(); + let [left, right] = node.namedChildren.filter(notComment); compileExpression(right, st); return union(st, compileLHS(left, st)); } function compileAugmentedAssignment(node: Parser.SyntaxNode, st: any) { - let [left, right] = node.namedChildren; + if (!node) return new Set(); + let [left, right] = node.namedChildren.filter(notComment); compileExpression(left, st); compileExpression(right, st); return new Set(); } function compileClassDefinition(node: Parser.SyntaxNode, st) { + if (!node) return new Set(); match(node.namedChildren.filter(notComment)) .with( [{ type: "identifier" }, { type: "argument_list" }, { type: "block" }], ([name, bases, body]) => { - bases.namedChildren.forEach((n) => { + bases.namedChildren.filter(notComment).forEach((n) => { compileExpression(n, st); }); compileBlock(body, st); @@ -453,11 +495,12 @@ function compileClassDefinition(node: Parser.SyntaxNode, st) { } function compileWithStatement(node: Parser.SyntaxNode, st: any) { - match(node.namedChildren) + if (!node) return new Set(); + match(node.namedChildren.filter(notComment)) .with( [{ type: "with_clause" }, { type: "block" }], ([withClause, body]) => { - st = withClause.namedChildren.reduce((acc, item) => { + st = withClause.namedChildren.filter(notComment).reduce((acc, item) => { let n = item.namedChild(0)!; switch (n.type) { case "as_pattern": @@ -482,6 +525,7 @@ function compileWithStatement(node: Parser.SyntaxNode, st: any) { } function compileStatement(node: Parser.SyntaxNode, st) { + if (!node) return new Set(); switch (node.type) { case "function_definition": return compileFunctionDefinition(node, st); @@ -534,27 +578,32 @@ function compileStatement(node: Parser.SyntaxNode, st) { } function compileWhileStatement(node: Parser.SyntaxNode, st) { - let [comp, body] = node.namedChildren; + if (!node) return new Set(); + let [comp, body] = node.namedChildren.filter(notComment); compileExpression(comp, st); compileBlock(body, st); return new Set(); } function compileRaiseStatement(node: Parser.SyntaxNode, st) { - node.namedChildren.forEach((n) => { + if (!node) return new Set(); + node.namedChildren.filter(notComment).forEach((n) => { compileExpression(n, st); }); return new Set(); } function compileBlock(node: Parser.SyntaxNode, st) { - return node.namedChildren.reduce((acc, n) => { + if (!node) return new Set(); + return node.namedChildren.filter(notComment).reduce((acc, n) => { return union(acc, compileStatement(n, acc)); }, st); } function compileArgs(node: Parser.SyntaxNode, st) { + if (!node) return new Set(); let argnames = node.namedChildren + .filter(notComment) .map((arg) => { switch (arg.type) { case "identifier": @@ -585,10 +634,10 @@ function compileArgs(node: Parser.SyntaxNode, st) { } function compileFunctionDefinition(node: Parser.SyntaxNode, st) { - let name = node.namedChildren[0]; - let args = node.namedChildren[1]; + if (!node) return new Set(); + let [name, args, ...bodies] = node.namedChildren.filter(notComment); // there may be (return) "type" or "comment" nodes in between. - let body = node.namedChildren[node.namedChildren.length - 1]; + let body = bodies[bodies.length - 1]; // parse parameters st = union(st, compileArgs(args, st)); compileBlock(body, union(st, new Set([name.text]))); @@ -596,17 +645,19 @@ function compileFunctionDefinition(node: Parser.SyntaxNode, st) { } function compileGeneratorExpression(node: Parser.SyntaxNode, st) { - let [exp, forin] = node.namedChildren; + if (!node) return new Set(); + let [exp, forin] = node.namedChildren.filter(notComment); compileForIn(forin, st, exp); return new Set(); } function compileCall(node: Parser.SyntaxNode, st) { - let [callee, args] = node.children; + if (!node) return new Set(); + let [callee, args] = node.namedChildren.filter(notComment); compileExpression(callee, st); switch (args.type) { case "argument_list": - args.namedChildren.forEach((n) => { + args.namedChildren.filter(notComment).forEach((n) => { compileExpression(n, st); }); break; @@ -623,14 +674,16 @@ function compileCall(node: Parser.SyntaxNode, st) { } function compileDictionary(node: Parser.SyntaxNode, st) { - node.namedChildren.forEach((n) => { + if (!node) return new Set(); + node.namedChildren.filter(notComment).forEach((n) => { compileExpression(n, st); }); return new Set(); } function compileConditionalExpression(node: Parser.SyntaxNode, st) { - let [left, cond, right] = node.namedChildren; + if (!node) return new Set(); + let [left, cond, right] = node.namedChildren.filter(notComment); compileExpression(cond, st); compileExpression(left, st); compileExpression(right, st); @@ -638,7 +691,8 @@ function compileConditionalExpression(node: Parser.SyntaxNode, st) { } function compileLambda(node: Parser.SyntaxNode, st) { - match(node.namedChildren) + if (!node) return new Set(); + match(node.namedChildren.filter(notComment)) .with([{ type: "lambda_parameters" }, P._], ([args, exp]) => { compileExpression(exp, union(st, compileArgs(args, st))); // compile exp @@ -658,6 +712,7 @@ function compileLambda(node: Parser.SyntaxNode, st) { } function compileExpression(node: Parser.SyntaxNode, st) { + if (!node) return new Set(); switch (node.type) { case "comment": return new Set(); @@ -694,11 +749,11 @@ function compileExpression(node: Parser.SyntaxNode, st) { case "await": return compileExpression(node.firstNamedChild!, st); case "attribute": - let [obj, attr] = node.namedChildren; + let [obj, attr] = node.namedChildren.filter(notComment); return compileExpression(obj, st); case "binary_operator": case "boolean_operator": { - let [left, , right] = node.children; + let [left, right] = node.namedChildren.filter(notComment); compileExpression(left, st); compileExpression(right, st); return new Set(); @@ -709,24 +764,24 @@ function compileExpression(node: Parser.SyntaxNode, st) { case "unary_operator": return compileExpression(node.namedChild(0)!, st); case "comparison_operator": { - let [left, right] = node.namedChildren; + let [left, right] = node.namedChildren.filter(notComment); compileExpression(left, st); compileExpression(right, st); return new Set(); } case "list": case "set": - return node.namedChildren.reduce((acc, n) => { + return node.namedChildren.filter(notComment).reduce((acc, n) => { return union(acc, compileExpression(n, acc)); }, st); case "tuple": - return node.namedChildren.reduce((acc, n) => { + return node.namedChildren.filter(notComment).reduce((acc, n) => { return union(acc, compileExpression(n, acc)); }, st); case "pair": // This is interesting, it is actually `a:b` in {a:b for a in [1,2,3] for b in [4,5,6]} - return node.namedChildren.reduce((acc, n) => { + return node.namedChildren.filter(notComment).reduce((acc, n) => { return union(acc, compileExpression(n, acc)); }, st); case "identifier": @@ -741,7 +796,7 @@ function compileExpression(node: Parser.SyntaxNode, st) { case "call": return compileCall(node, st); case "expression_list": - return node.namedChildren.reduce((acc, n) => { + return node.namedChildren.filter(notComment).reduce((acc, n) => { return union(acc, compileExpression(n, acc)); }, st); case "list_comprehension": @@ -753,12 +808,13 @@ function compileExpression(node: Parser.SyntaxNode, st) { case "conditional_expression": return compileConditionalExpression(node, st); case "slice": - node.namedChildren.forEach((n) => { + node.namedChildren.filter(notComment).forEach((n) => { compileExpression(n, st); }); return new Set(); case "subscript": return node.namedChildren + .filter(notComment) .map((n) => compileExpression(n, st)) .reduce(union); default: @@ -773,6 +829,7 @@ function compileExpression(node: Parser.SyntaxNode, st) { } function compileModuleItem(node, st) { + if (!node) return new Set(); switch (node.type) { case "expression": return compileExpression(node, st); diff --git a/ui/src/lib/store/canvasSlice.tsx b/ui/src/lib/store/canvasSlice.tsx index a2558455..6a9cea5a 100644 --- a/ui/src/lib/store/canvasSlice.tsx +++ b/ui/src/lib/store/canvasSlice.tsx @@ -15,7 +15,20 @@ import { Transaction, YEvent } from "yjs"; import { match, P } from "ts-pattern"; -import { myNanoId, nodetype2dbtype, dbtype2nodetype } from "../utils"; +import { + forceSimulation, + forceLink, + forceManyBody, + forceCollide, + forceCenter, + forceX, + forceY, + SimulationNodeDatum, + SimulationLinkDatum, +} from "d3-force"; +import { YMap } from "yjs/dist/src/types/YMap"; + +import { myNanoId } from "../utils"; import { Connection, @@ -35,7 +48,8 @@ import { ReactFlowInstance, } from "reactflow"; import { node } from "prop-types"; -import { fixTables } from "@remirror/pm/dist-types/tables"; +import { quadtree } from "d3-quadtree"; +import { getHelperLines } from "../../components/nodes/utils"; // TODO add node's data typing. type NodeData = { @@ -69,21 +83,20 @@ function createTemporaryNode(pod, position, parent = "ROOT", level = 0): any { width: pod.width, }; - if (pod.type === "DECK") { + if (pod.type === "SCOPE") { style["height"] = pod.height!; style["backgroundColor"] = level2color[level] || level2color["default"]; } const newNode = { id, - type: dbtype2nodetype(pod.type), + type: pod.type, position, data: { label: id, parent, level, }, - extent: level > 0 ? "parent" : undefined, dragHandle: ".custom-drag-handle", width: pod.width, height: pod.height!, @@ -109,19 +122,35 @@ function createTemporaryNode(pod, position, parent = "ROOT", level = 0): any { /** * The new reactflow nodes for context-menu's addXXX items. */ -function createNewNode(type: "scope" | "code" | "rich", position): Node { +function createNewNode(type: "SCOPE" | "CODE" | "RICH", position): Node { let id = myNanoId(); const newNode = { id, type, position, - ...(type === "scope" + ...(type === "SCOPE" ? { width: 600, height: 600, style: { backgroundColor: level2color[0], width: 600, height: 600 }, } - : { width: 300, style: { width: 300 } }), + : { + width: 300, + // Previously, we should not specify height, so that the pod can grow + // when content changes. But when we add auto-layout on adding a new + // node, unspecified height will cause the node to be added always at + // the top-left corner (the reason is unknown). Thus, we have to + // specify the height here. Note that this height is a dummy value; + // the content height will still be adjusted based on content height. + height: 200, + style: { + width: 300, + // It turns out that this height should not be specified to let the + // height change automatically. + // + // height: 200 + }, + }), data: { label: id, name: "", @@ -133,16 +162,18 @@ function createNewNode(type: "scope" | "code" | "rich", position): Node { return newNode; } -function getAbsPos(node: Node, nodesMap) { +/** + * Get the absoluate position of the node. + */ +function getAbsPos(node: Node, nodesMap: YMap): XYPosition { let x = node.position.x; let y = node.position.y; - if (node.parentNode) { - // FIXME performance. - let [dx, dy] = getAbsPos(nodesMap.get(node.parentNode), nodesMap); - return [x + dx, y + dy]; - } else { - return [x, y]; + while (node.parentNode) { + node = nodesMap.get(node.parentNode)!; + x += node.position.x; + y += node.position.y; } + return { x, y }; } function getScopeAt( @@ -153,9 +184,9 @@ function getScopeAt( nodesMap ): Node { const scope = nodes.findLast((node) => { - let [x1, y1] = getAbsPos(node, nodesMap); + let { x: x1, y: y1 } = getAbsPos(node, nodesMap); return ( - node.type === "scope" && + node.type === "SCOPE" && x >= x1 && !excludes.includes(node.id) && x <= x1 + node.width && @@ -169,20 +200,14 @@ function getScopeAt( function getNodePositionInsideScope( node: Node, scope: Node, - nodesMap + nodesMap, + nodeHeight: number = 0 ): XYPosition { // compute the actual position - let [x, y] = getAbsPos(node, nodesMap); - let [dx, dy] = getAbsPos(scope, nodesMap); + let { x, y } = getAbsPos(node, nodesMap); + let { x: dx, y: dy } = getAbsPos(scope, nodesMap); x -= dx; y -= dy; - // auto-align the node to, keep it bound in the scope - // FIXME: it assumes the scope must be larger than the node - - x = Math.max(x, 0); - x = Math.min(x, scope.width! - node.width!); - y = Math.max(y, 0); - y = Math.min(y, scope.height! - node.height!); return { x, y }; } @@ -250,7 +275,13 @@ export interface CanvasSlice { setPaneFocus: () => void; setPaneBlur: () => void; - addNode: (type: "code" | "scope" | "rich", position: XYPosition) => void; + addNode: ( + type: "CODE" | "SCOPE" | "RICH", + position: XYPosition, + parent: string + ) => void; + + setNodeCharWidth: (id: string, width: number) => void; pastingNodes?: Node[]; headPastingNodes?: Set; @@ -271,12 +302,21 @@ export interface CanvasSlice { adjustLevel: () => void; getScopeAtPos: ({ x, y }: XYPosition, exclude: string) => Node | undefined; moveIntoScope: (nodeId: string, scopeId: string) => void; - moveIntoRoot: (nodeId: string) => void; tempUpdateView: ({ x, y }: XYPosition) => void; + helperLineHorizontal: number | undefined; + helperLineVertical: number | undefined; + setHelperLineHorizontal: (line: number | undefined) => void; + setHelperLineVertical: (line: number | undefined) => void; + onNodesChange: (client: ApolloClient) => OnNodesChange; onEdgesChange: (client: ApolloClient) => OnEdgesChange; onConnect: (client: ApolloClient) => OnConnect; + + node2children: Map; + buildNode2Children: () => void; + autoLayout: (scopeId: string) => void; + autoLayoutROOT: () => void; } export const createCanvasSlice: StateCreator = ( @@ -357,7 +397,7 @@ export const createCanvasSlice: StateCreator = ( style: { ...node.style, backgroundColor: - node.type === "scope" ? level2color[node.data.level] : undefined, + node.type === "SCOPE" ? level2color[node.data.level] : undefined, }, selected: selectedPods.has(node.id), // className: get().dragHighlight === node.id ? "active" : "", @@ -390,8 +430,8 @@ export const createCanvasSlice: StateCreator = ( get().addPod({ id: node.id, children: [], - parent, - type: nodetype2dbtype(node.type || ""), + parent: "ROOT", + type: node.type as "CODE" | "SCOPE" | "RICH", lang: "python", x: node.position.x, y: node.position.y, @@ -401,6 +441,41 @@ export const createCanvasSlice: StateCreator = ( dirty: true, pending: true, }); + if (parent !== "ROOT") { + // we don't assign its parent when created, because we have to adjust its position to make it inside its parent. + get().moveIntoScope(node.id, parent); + } else { + // moveIntoScope will build the node2children map, but we need to build it here for the ROOT nodes. + get().buildNode2Children(); + } + // Set initial width as about 30 characters. + get().setNodeCharWidth(node.id, 30); + get().updateView(); + // run auto-layout + if (get().autoRunLayout) { + get().autoLayoutROOT(); + } + }, + + setNodeCharWidth: (id, width) => { + let nodesMap = get().ydoc.getMap("pods"); + let node = nodesMap.get(id); + if (!node) return; + // I'll need to map this character width into the width of the node, taking into consideration of the font size. + console.log("setNodeCharWidth", width, node.data.level); + // calculate the actual width given the fontSzie and the character width + const fontsize = get().level2fontsize(node.data.level); + // the fontSize is in pt, but the width is in px + width = width * fontsize * 0.67; + nodesMap.set(id, { ...node, width, style: { ...node.style, width } }); + let geoData = { + parent: node.parentNode ? node.parentNode : "ROOT", + x: node.position.x, + y: node.position.y, + width: width, + height: node.height!, + }; + get().setPodGeo(node.id, geoData, true); get().updateView(); }, @@ -572,62 +647,63 @@ export const createCanvasSlice: StateCreator = ( } }); }, - moveIntoRoot: (nodeId: string) => { - console.log("Moving into root", nodeId); + moveIntoScope: (nodeId: string, scopeId: string) => { + // move a node into a scope. + // 1. update the node's parentNode & position let nodesMap = get().ydoc.getMap("pods"); let node = nodesMap.get(nodeId); if (!node) { console.warn("Node not found", node); return; } - let newNode: Node = { - ...node, - parentNode: undefined, - extent: undefined, - data: { - ...node.data, - level: 0, - }, - }; - nodesMap.set(node.id, newNode); - // update zustand & db - get().setPodGeo(node.id, { parent: "ROOT" }, true); - get().adjustLevel(); - // update view - get().updateView(); - }, - - moveIntoScope: (nodeId: string, scopeId: string) => { - console.log(`Moving ${nodeId} into scope ${scopeId}`); - // move a node into a scope. - // 1. update the node's parentNode & position - let nodesMap = get().ydoc.getMap("pods"); - let node = nodesMap.get(nodeId); - let scope = nodesMap.get(scopeId); - if (!node || !scope) { - console.warn("Node or scope not found", node, scope); + if ( + node.parentNode === scopeId || + (scopeId === "ROOT" && node.parentNode === undefined) + ) { + console.warn("Node already in scope", node); return; } - // let [x, y] = getAbsPos(node, nodesMap); - // let position = getNodePositionInsideParent(node, scope, { x, y }); - let position = getNodePositionInsideScope(node, scope, nodesMap); + console.log(`Moving ${nodeId} into scope ${scopeId}`); + let fromLevel = node?.data.level; + let toLevel: number; + let position: XYPosition; + if (scopeId === "ROOT") { + toLevel = 0; + position = getAbsPos(node, nodesMap); + } else { + let scope = nodesMap.get(scopeId); + if (!node || !scope) { + console.warn("Scope not found", scope); + return; + } + toLevel = scope.data.level + 1; + // FIXME: since richNode and codeNode doesn't have height when it's created, we have to pass its height manually in case crash. + const nodeHeight = get().getPod(nodeId)?.height || 0; + position = getNodePositionInsideScope(node, scope, nodesMap, nodeHeight); + } + // need to adjust the node width according to the from and to scopes + const fromFontSize = get().level2fontsize(fromLevel); + const toFontSize = get().level2fontsize(toLevel); + const newWidth = node.width! * (toFontSize / fromFontSize); + // create the new node let newNode: Node = { ...node, position, - parentNode: scope.id, - extent: "parent", + parentNode: scopeId === "ROOT" ? undefined : scopeId, + width: newWidth, data: { ...node.data, - level: scope.data.level + 1, + level: toLevel, }, }; // update peer nodesMap.set(node.id, newNode); // update zustand & db - get().setPodGeo(node.id, { parent: scope.id, ...position }, true); + get().setPodGeo(node.id, { parent: scopeId, ...position }, true); get().adjustLevel(); // update view get().updateView(); + get().buildNode2Children(); }, tempUpdateView: (position) => { @@ -640,11 +716,62 @@ export const createCanvasSlice: StateCreator = ( }); }, + helperLineHorizontal: undefined, + helperLineVertical: undefined, + setHelperLineHorizontal: (line) => set({ helperLineHorizontal: line }), + setHelperLineVertical: (line) => set({ helperLineVertical: line }), + // I should modify nodesMap here onNodesChange: (client) => (changes: NodeChange[]) => { let nodesMap = get().ydoc.getMap("pods"); const nodes = get().nodes; + // compute the helper lines + get().setHelperLineHorizontal(undefined); + get().setHelperLineVertical(undefined); + + // this will be true if it's a single node being dragged + // inside we calculate the helper lines and snap position for the position where the node is being moved to + if ( + changes.length === 1 && + changes[0].type === "position" && + changes[0].dragging && + changes[0].position + ) { + // For hierarchical pods, we only get helper lines within the same scope. + const change = changes[0]; + const movingNode = nodesMap.get(change.id)!; + + // distance is the sensitivity for snapping to helper lines. + const distance = 10; + const helperLines = getHelperLines( + changes[0], + nodes.filter((n) => n.parentNode === movingNode.parentNode), + distance + ); + + // adjust the position into absolute position + if (movingNode.parentNode) { + const parent = nodesMap.get(movingNode.parentNode)!; + // const offset = parent?.positionAbsolute; + // const offset = parent?.position; + const offset = getAbsPos(parent, nodesMap); + helperLines.vertical && (helperLines.vertical += offset?.x || 0); + helperLines.horizontal && (helperLines.horizontal += offset?.y || 0); + } + + // if we have a helper line, we snap the node to the helper line position + // this is being done by manipulating the node position inside the change object + changes[0].position.x = + helperLines.snapPosition.x ?? changes[0].position.x; + changes[0].position.y = + helperLines.snapPosition.y ?? changes[0].position.y; + + // if helper lines are returned, we set them so that they can be displayed + get().setHelperLineHorizontal(helperLines.horizontal); + get().setHelperLineVertical(helperLines.vertical); + } + // I think this place update the node's width/height const nextNodes = applyNodeChanges(changes, nodes); @@ -738,6 +865,9 @@ export const createCanvasSlice: StateCreator = ( nodesMap.delete(change.id); // remove from store get().deletePod(client, { id: change.id }); + get().buildNode2Children(); + // run auto-layout + get().autoLayoutROOT(); break; default: // should not reach here. @@ -799,8 +929,320 @@ export const createCanvasSlice: StateCreator = ( }, setPaneFocus: () => set({ isPaneFocused: true }), setPaneBlur: () => set({ isPaneFocused: false }), + /** + * This node2children is maintained with the canvas reactflow states, not with + * the pods. This mapping may be used by other components, e.g. the runtime. + * + * TODO we should optimize the performance of this function, maybe only update + * the mapping when the structure is changed. + */ + node2children: new Map(), + buildNode2Children: () => { + console.debug("Building node2children.."); + // build a map from node to its children + let nodesMap = get().ydoc.getMap("pods"); + let nodes: Node[] = Array.from(nodesMap.values()); + let node2children = new Map(); + node2children.set("ROOT", []); + nodes.forEach((node) => { + if (!node2children.has(node.id)) { + node2children.set(node.id, []); + } + if (node.parentNode) { + if (!node2children.has(node.parentNode)) { + node2children.set(node.parentNode, []); + } + node2children.get(node.parentNode)?.push(node.id); + } else { + node2children.get("ROOT")?.push(node.id); + } + }); + set({ node2children }); + }, + autoLayoutROOT: () => { + // get all scopes, + let nodesMap = get().ydoc.getMap("pods"); + let nodes: Node[] = Array.from(nodesMap.values()); + nodes + // sort the children so that the inner scope gets processed first. + .sort((a: Node, b: Node) => b.data.level - a.data.level) + .forEach((node) => { + if (node.type === "SCOPE") { + get().autoLayout(node.id); + } + }); + // Applying on ROOT scope is not ideal. + get().autoLayout("ROOT"); + }, + /** + * Use d3-force to auto layout the nodes. + */ + autoLayout: (scopeId) => { + // 1. get all the nodes and edges in the scope + let nodesMap = get().ydoc.getMap("pods"); + const nodes = get().nodes.filter( + (node) => node.parentNode === (scopeId === "ROOT" ? undefined : scopeId) + ); + if (nodes.length == 0) return; + const edges = get().edges; + // consider the output box + const id2height = new Map(); + const id2width = new Map(); + // Leave some room for the top toolbar. + // FIXME fixed value. + const paddingTopPod = 50; + nodes.forEach((node) => { + const bottom = document.querySelector(`#result-${node.id}-bottom`); + const right = document.querySelector("#result-" + node.id + "-right"); + const boxheight = bottom?.clientHeight || 0; + const boxwidth = right?.clientWidth || 0; + // FIXME a scope's height is NaN + id2height.set(node.id, (node.height || 0) + boxheight); + id2width.set(node.id, (node.width || 0) + boxwidth); + // id2height.set(node.id, node.height!); + }); + // Save initial minimum offset of the nodes. + let initOffX = Math.min(...nodes.map((node) => node.position.x)); + let initOffY = Math.min(...nodes.map((node) => node.position.y)); + + const tmpNodes: NodeType[] = nodes.map((node) => ({ + id: node.id, + x: node.position.x + id2width.get(node.id)! / 2, + y: node.position.y + id2height.get(node.id)! / 2, + width: id2width.get(node.id)!, + height: id2height.get(node.id)! + paddingTopPod, + })); + const tmpEdges = edges.map((edge) => ({ + source: edge.source, + source0: 0, + target: edge.target, + target0: 1, + })); + // 2. construct a D3 tree for the nodes and their connections + // initialize the tree layout (see https://observablehq.com/@d3/tree for examples) + // const hierarchy = stratify() + // .id((d) => d.id) + // // get the id of each node by searching through the edges + // // this only works if every node has one connection + // .parentId((d: Node) => edges.find((e: Edge) => e.target === d.id)?.source)( + // nodes + // ); + const simulation = forceSimulation(tmpNodes) + // .force( + // "link", + // forceLink(tmpEdges) + // .id((d: any) => d.id) + // .distance(20) + // .strength(0.5) + // ) + // .force("charge", forceManyBody().strength(-1000)) + // .force("x", forceX()) + // .force("y", forceY()) + .force("collide", forceCollideRect()) + // .force("link", d3.forceLink(edges).id(d => d.id)) + // .force("charge", d3.forceManyBody()) + // .force("center", forceCenter(0, 0)) + .stop(); + simulation.tick(10); + tmpNodes.forEach((node) => { + node.x -= id2width.get(node.id)! / 2; + node.y -= id2height.get(node.id)! / 2; + }); + + if (scopeId === "ROOT") { + // reset the node positions + tmpNodes.forEach(({ id, x, y }) => { + // FIXME I should assert here. + if (nodesMap.has(id)) { + nodesMap.set(id, { + ...nodesMap.get(id)!, + position: { x, y }, + }); + } + }); + } else { + // The nodes will all have new positions now. I'll need to make the graph to be top-left, i.e., the leftmost is 20, the topmost is 20. + // get the min x and y + let x1s = tmpNodes.map((node) => node.x); + let minx = Math.min(...x1s); + let y1s = tmpNodes.map((node) => node.y); + let miny = Math.min(...y1s); + // calculate the offset, leave 50 padding for the scope. + // Leave some room at the top of the scope for inner pod toolbars. + const paddingTop = 70; + const paddingBottom = 50; + const paddingLeft = 50; + const paddingRight = 50; + const offsetx = paddingLeft - minx; + const offsety = paddingTop - miny; + // move the nodes + tmpNodes.forEach((node) => { + node.x += offsetx; + node.y += offsety; + }); + // Apply the new positions + // TODO need to transform the nodes to the center of the scope. + tmpNodes.forEach(({ id, x, y }) => { + // FIXME I should assert here. + if (nodesMap.has(id)) { + nodesMap.set(id, { + ...nodesMap.get(id)!, + // position: { x: x + scope!.position!.x, y: y + scope!.position!.y }, + position: { x, y }, + }); + } + }); + // update the scope's size to enclose all the nodes + x1s = tmpNodes.map((node) => node.x); + minx = Math.min(...x1s); + y1s = tmpNodes.map((node) => node.y); + miny = Math.min(...y1s); + const x2s = tmpNodes.map((node) => node.x + id2width.get(node.id)!); + const maxx = Math.max(...x2s); + const y2s = tmpNodes.map((node) => node.y + id2height.get(node.id)!); + const maxy = Math.max(...y2s); + const scope = nodesMap.get(scopeId)!; + nodesMap.set(scopeId, { + ...scope, + position: { + x: scope.position.x + initOffX - paddingLeft, + y: scope.position.y + initOffY - paddingTop, + }, + width: maxx - minx + paddingLeft + paddingRight, + height: maxy - miny + paddingTop + paddingBottom, + style: { + ...scope!.style, + width: maxx - minx + paddingLeft + paddingRight, + height: maxy - miny + paddingTop + paddingBottom, + }, + }); + } + + // trigger update to the db + // get the most recent nodes + let newNodes = Array.from(nodesMap.values()); + newNodes.forEach((node) => { + // trigger update to the DB + let geoData = { + parent: node.parentNode ? node.parentNode : "ROOT", + x: node.position.x, + y: node.position.y, + width: node.width!, + height: node.height!, + }; + get().setPodGeo(node.id, geoData, true); + }); + + get().updateView(); + }, }); +type NodeType = { + id: string; + x: number; + y: number; + width: number; + height: number; +}; + +function forceCollideRect() { + let nodes; + + function force(alpha) { + const padding = 5; + const quad = quadtree( + nodes, + (d: NodeType) => d.x, + (d) => d.y + ); + for (const d of nodes) { + quad.visit((q: any, x1, y1, x2, y2) => { + let updated = false; + if (q.data && q.data !== d) { + let x = d.x - q.data.x, + y = d.y - q.data.y, + xSpacing = padding + (q.data.width + d.width) / 2, + ySpacing = padding + (q.data.height + d.height) / 2, + absX = Math.abs(x), + absY = Math.abs(y), + l, + lx, + ly; + + if (absX < xSpacing && absY < ySpacing) { + l = Math.sqrt(x * x + y * y); + + lx = (absX - xSpacing) / l; + ly = (absY - ySpacing) / l; + + // the one that's barely within the bounds probably triggered the collision + if (Math.abs(lx) > Math.abs(ly)) { + lx = 0; + } else { + ly = 0; + } + d.x -= x *= lx; + d.y -= y *= ly; + q.data.x += x; + q.data.y += y; + + updated = true; + } + } + return updated; + }); + } + } + + force.initialize = (_) => (nodes = _); + + return force; +} + +/** + * Compute the rectangle of that is tightly fit to the children. + * @param node + * @param node2children + * @param nodesMap + * @returns {x,y,width,height} + */ +function fitChildren( + node: Node, + node2children, + nodesMap +): null | { x: number; y: number; width: number; height: number } { + if (node.type !== "SCOPE") return null; + // This is a scope node. Get all its children and calculate the x,y,width,height to tightly fit its children. + let children = node2children.get(node.id); + // If no children, nothing is changed. + if (children.length === 0) return null; + // These positions are actually relative to the parent node. + let x1s = children.map((child) => nodesMap.get(child)!.position.x); + let minx = Math.min(...x1s); + let y1s = children.map((child) => nodesMap.get(child)!.position.y); + let miny = Math.min(...y1s); + let x2s = children.map((child) => { + let n = nodesMap.get(child)!; + return n.position.x + n.width!; + }); + let maxx = Math.max(...x2s); + let y2s = children.map((child) => { + let n = nodesMap.get(child)!; + return n.position.y + n.height!; + }); + let maxy = Math.max(...y2s); + let width = maxx - minx; + let height = maxy - miny; + const padding = 50; + return { + // leave a 50 padding for the scope. + x: minx - padding, + y: miny - padding, + width: width + padding * 2, + height: height + padding * 2, + }; +} + async function remoteAddEdge({ client, source, target }) { const mutation = gql` mutation addEdge($source: ID!, $target: ID!) { diff --git a/ui/src/lib/store/index.tsx b/ui/src/lib/store/index.tsx index c75221e1..cc968e24 100644 --- a/ui/src/lib/store/index.tsx +++ b/ui/src/lib/store/index.tsx @@ -17,7 +17,7 @@ enableMapSet(); export type Pod = { id: string; name?: string; - type: string; + type: "CODE" | "SCOPE" | "RICH"; content?: string; dirty?: boolean; isSyncing?: boolean; @@ -28,6 +28,7 @@ export type Pod = { stdout?: string; stderr?: string; error?: { evalue: string; stacktrace: string[] } | null; + lastExecutedAt?: Date; lang: string; column?: number; raw?: boolean; @@ -35,6 +36,7 @@ export type Pod = { symbolTable?: { [key: string]: string }; annotations?: Annotation[]; ispublic?: boolean; + isbridge?: boolean; x: number; y: number; width?: number; diff --git a/ui/src/lib/store/podSlice.tsx b/ui/src/lib/store/podSlice.tsx index 26f9321e..643a21e4 100644 --- a/ui/src/lib/store/podSlice.tsx +++ b/ui/src/lib/store/podSlice.tsx @@ -49,6 +49,12 @@ export interface PodSlice { content: { data: { html: string; text: string; image: string } }; count: number; }) => void; + setPodDisplayData: ({ id, content, count }) => void; + setPodExecuteReply: ({ id, result, count }) => void; + setPodStdout: ({ id, stdout }: { id: string; stdout: string }) => void; + setPodError: ({ id, ename, evalue, stacktrace }) => void; + setPodStream: ({ id, content }) => void; + setPodStatus: ({ id, status, lang }) => void; clonePod: (id: string) => any; } @@ -144,6 +150,7 @@ export const createPodSlice: StateCreator = ( set( produce((state) => { state.pods[id].stdout = stdout; + state.pods[id].dirty = true; }) ), setPodResult: setPodResult(set, get), @@ -157,6 +164,7 @@ export const createPodSlice: StateCreator = ( image: content.data["image/png"], count: count, }; + state.pods[id].dirty = true; }) ), setPodExecuteReply: ({ id, result, count }) => @@ -170,6 +178,7 @@ export const createPodSlice: StateCreator = ( // }; // console.log("WS_EXECUTE_REPLY", result); state.pods[id].running = false; + state.pods[id].lastExecutedAt = Date.now(); if (!state.pods[id].result) { state.pods[id].result = { text: result, @@ -192,6 +201,7 @@ export const createPodSlice: StateCreator = ( evalue, stacktrace, }; + state.pods[id].dirty = true; }) ), setPodStream: ({ id, content }) => @@ -215,6 +225,7 @@ export const createPodSlice: StateCreator = ( }; } pod.stdout += content.text; + pod.dirty = true; }) ), setPodExecuteResult: ({ id, result, name }) => @@ -364,6 +375,7 @@ function setPodResult(set, get) { // file, count, }; + state.pods[id].dirty = true; // state.pods[id].running = false; } else { // most likely this id is "CODEPOD", which is for startup code and diff --git a/ui/src/lib/store/runtimeSlice.tsx b/ui/src/lib/store/runtimeSlice.tsx index 849cd786..111bfc1c 100644 --- a/ui/src/lib/store/runtimeSlice.tsx +++ b/ui/src/lib/store/runtimeSlice.tsx @@ -2,6 +2,8 @@ import produce from "immer"; import { ApolloClient, gql } from "@apollo/client"; import { createStore, StateCreator, StoreApi } from "zustand"; +import { Edge, Node } from "reactflow"; + // FIXME cyclic import import { MyState } from "."; import { analyzeCode, analyzeCodeViaQuery } from "../parser"; @@ -9,29 +11,53 @@ import { analyzeCode, analyzeCodeViaQuery } from "../parser"; /** * Collect symbol tables from all the pods in scope. */ -function collectSymbolTables({ id, get }: { id: string; get: () => MyState }) { +function collectSymbolTables( + id: string, + get: () => MyState +): Record { let pods = get().pods; let pod = pods[id]; + const isbridge = pod.isbridge; // Collect from parent scope. - if (!pod.parent) return {}; - let allSymbolTables = pods[pod.parent].children.map(({ id, type }) => { - // FIXME make this consistent, CODE, POD, DECK, SCOPE; use enums - if (pods[id].type === "CODE") { - return pods[id].symbolTable || {}; - } else { - // FIXME dfs, or re-export? - let tables = (pods[id].children || []) - .filter(({ id }) => pods[id].ispublic) - .map(({ id }) => pods[id].symbolTable); - return Object.assign({}, ...tables); - } - }); + let parentId = pod.parent; + let allSymbolTables: Record[] = []; + // do this for all ancestor scopes. + while (parentId) { + let siblings = get().node2children.get(parentId) || []; + const tables = siblings.map((_id) => { + // FIXME make this consistent, CODE, POD, DECK, SCOPE; use enums + if (pods[_id].type === "CODE") { + if (isbridge && _id === id) { + // The key to support recursive export bridge are: + // 1. not to add the name to symbol table when resolving this bridge + // pod, so that we can correctly set name_thisScope = + // name_originScope. + // 2. do add the name to symbol table when resolving other pods, so + // that other pods can see its definition. + return {}; + } else { + return pods[_id].symbolTable || {}; + } + } else { + // FIXME dfs, or re-export? + let tables = (pods[_id].children || []) + .filter(({ id }) => pods[id].ispublic) + .map(({ id }) => pods[id].symbolTable); + return Object.assign({}, ...tables); + } + }); + allSymbolTables.push(Object.assign({}, ...tables)); + if (parentId === "ROOT") break; + let parentPod = pods[parentId]; + parentId = parentPod.parent; + } + // collect from all ancestor scopes. // Collect from scopes by Arrows. const edges = get().edges; edges.forEach(({ source, target }) => { if (target === pod.parent) { if (pods[source].type === "CODE") { - allSymbolTables.push(pods[target].symbolTable); + allSymbolTables.push(pods[target].symbolTable || {}); } else { let tables = (pods[source].children || []) .filter(({ id }) => pods[id].ispublic) @@ -41,7 +67,11 @@ function collectSymbolTables({ id, get }: { id: string; get: () => MyState }) { } }); // Combine the tables and return. - let res = Object.assign({}, pods[id].symbolTable, ...allSymbolTables); + let res: Record = Object.assign( + {}, + pods[id].symbolTable, + ...allSymbolTables + ); return res; } @@ -87,6 +117,17 @@ function rewriteCode(id: string, get: () => MyState) { newcode += annotation.name; } break; + case "bridge": + // replace "@export x" with "x_thisScope = x_originScope" + if (annotation.origin) { + newcode += `${annotation.name}_${pods[id].parent} = ${ + annotation.name + }_${pods[annotation.origin].parent}`; + } else { + console.log("bridge not found", annotation.name); + newcode += annotation.name; + } + break; default: throw new Error("unknown annotation type: " + annotation.type); } @@ -163,7 +204,13 @@ export interface RuntimeSlice { parseAllPods: () => void; resolvePod: (id) => void; resolveAllPods: () => void; - wsRun: (id) => void; + runningId: string | null; + wsRun: (id: string) => void; + wsRunScope: (id: string) => void; + wsSendRun: (id: string) => void; + wsRunNext: () => void; + chain: string[]; + wsRunChain: (id: string) => void; wsInterruptKernel: ({ lang }) => void; clearResults: (id) => void; clearAllResults: () => void; @@ -203,13 +250,18 @@ export const createRuntimeSlice: StateCreator = ( set( produce((state) => { let analyze = get().scopedVars ? analyzeCode : analyzeCodeViaQuery; - let { ispublic, annotations } = analyze(state.pods[id].content); + let { ispublic, isbridge, annotations } = analyze( + state.pods[id].content + ); state.pods[id].ispublic = ispublic; + state.pods[id].isbridge = isbridge; state.pods[id].symbolTable = Object.assign( {}, ...annotations - .filter(({ type }) => ["function", "vardef"].includes(type)) + .filter(({ type }) => + ["function", "vardef", "bridge"].includes(type) + ) .map(({ name }) => ({ [name]: id, })) @@ -228,7 +280,7 @@ export const createRuntimeSlice: StateCreator = ( }, resolvePod: (id) => { // 1. collect symbol table - let st = collectSymbolTables({ id, get }); + let st = collectSymbolTables(id, get); // 2. resolve symbols set( produce((state) => { @@ -251,7 +303,22 @@ export const createRuntimeSlice: StateCreator = ( } }); }, - wsRun: async (id) => { + // This runningId is a unique pod id indicating which pod is being run. The + // state.pods[id].running is a indicator of the pod in the chain that is + // scheduled to run. + runningId: null, + /** + * Actually send the run request. + */ + wsSendRun: async (id) => { + if (get().runningId !== null) { + // This should never happen: there shouldn't be another pod running. + get().addError({ + type: "error", + msg: "Another pod is running", + }); + return; + } if (!get().socket) { get().addError({ type: "error", @@ -259,12 +326,16 @@ export const createRuntimeSlice: StateCreator = ( }); return; } + // Set this pod as running. + set({ runningId: id }); + // Actually send the run request. // Analyze code and set symbol table get().parsePod(id); // update anontations according to st get().resolvePod(id); // rewrite the code const newcode = rewriteCode(id, get); + // Run the code in remote kernel. get().setRunning(id); let pod = get().pods[id]; @@ -281,6 +352,117 @@ export const createRuntimeSlice: StateCreator = ( }) ); }, + // All pods are added to the chain before executing. + chain: [], + /** + * Add a pod to the chain and run it. + */ + wsRun: async (id) => { + if (!get().socket) { + get().addError({ + type: "error", + msg: "Runtime not connected", + }); + return; + } + // If this pod is a code pod, add it. + if (get().pods[id].type === "CODE") { + // Add to the chain + get().clearResults(id); + get().setRunning(id); + set({ chain: [...get().chain, id] }); + } else if (get().pods[id].type === "SCOPE") { + // If this pod is a scope, run all pods inside a scope by geographical order. + // get the pods in the scope + let children = get().node2children.get(id); + if (!children) return; + // The reactflow nodesMap stored in Yjs + let nodesMap = get().ydoc.getMap("pods"); + // Sort by x and y positions, with the leftmost and topmost first. + children = [...children].sort((a, b) => { + let nodeA = nodesMap.get(a); + let nodeB = nodesMap.get(b); + if (nodeA && nodeB) { + if (nodeA.position.y === nodeB.position.y) { + return nodeA.position.x - nodeB.position.x; + } else { + return nodeA.position.y - nodeB.position.y; + } + } else { + return 0; + } + }); + // add to the chain + // set({ chain: [...get().chain, ...children.map(({ id }) => id)] }); + children.forEach((id) => get().wsRun(id)); + } + get().wsRunNext(); + }, + wsRunScope: async (id) => { + // This is a separate function only because we need to build the node2children map first. + get().wsRun(id); + }, + /** + * Add the pod and all its downstream pods (defined by edges) to the chain and run the chain. + * @param id the id of the pod to start the chain + * @returns + */ + wsRunChain: async (id) => { + if (!get().socket) { + get().addError({ + type: "error", + msg: "Runtime not connected", + }); + return; + } + // Get the chain: get the edges, and then get the pods + const edgesMap = get().ydoc.getMap("edges"); + let edges = Array.from(edgesMap.values()); + // build a node2target map + let node2target = {}; + edges.forEach(({ source, target }) => { + // TODO support multiple targets + node2target[source] = target; + }); + // Get the chain + let chain: string[] = []; + let node = id; + while (node) { + // if the node is already in the chain, then there is a loop + if (chain.includes(node)) break; + get().clearResults(node); + get().setRunning(node); + chain.push(node); + node = node2target[node]; + } + set({ chain }); + get().wsRunNext(); + }, + wsRunNext: async () => { + // run the next pod in the chain + if (get().runningId !== null) return; + if (get().chain.length > 0) { + // Run the first pod in the chain + let chain = get().chain; + let id = chain[0]; + console.log("running", id, "remaining number of pods:", chain.length - 1); + // remove the first element + set({ chain: chain.slice(1) }); + // If the pod is empty, the kernel won't reply. So, we need to skip it. + if ( + get().pods[id].content === undefined || + get().pods[id].content === "" + ) { + set( + produce((state) => { + state.pods[id].running = false; + }) + ); + } else { + get().wsSendRun(id); + } + } + }, wsInterruptKernel: ({ lang }) => { get().socket!.send( JSON.stringify({ @@ -298,6 +480,7 @@ export const createRuntimeSlice: StateCreator = ( state.pods[id].result = null; state.pods[id].stdout = ""; state.pods[id].error = null; + state.pods[id].dirty = true; }) ); }, @@ -308,6 +491,7 @@ export const createRuntimeSlice: StateCreator = ( state.pods[id].result = null; state.pods[id].stdout = ""; state.pods[id].error = null; + state.pods[id].dirty = true; }); }) ); @@ -416,7 +600,7 @@ function wsConnect(set, get: () => MyState) { }; } -function onMessage(set, get) { +function onMessage(set, get: () => MyState) { return (msg) => { // console.log("onMessage", msg.data || msg.body || undefined); // msg.data for websocket @@ -449,6 +633,9 @@ function onMessage(set, get) { { let { podId, result, count } = payload; get().setPodExecuteReply({ id: podId, result, count }); + set({ runningId: null }); + // Continue to run the chain if there is any. + get().wsRunNext(); } break; case "error": @@ -463,21 +650,6 @@ function onMessage(set, get) { get().setPodStream({ id: podId, content }); } break; - case "IO:execute_result": - { - let { podId, result, name } = payload; - get().setPodExecuteResult({ id: podId, result, name }); - } - break; - case "IO:execute_reply": - // CAUTION ignore - break; - case "IO:error": - { - let { podId, name, ename, evalue, stacktrace } = payload; - get().setIOResult({ id: podId, name, ename, evalue, stacktrace }); - } - break; case "status": { const { lang, status, id } = payload; diff --git a/ui/src/lib/store/settingSlice.tsx b/ui/src/lib/store/settingSlice.tsx index 6f77b9b7..5749db1d 100644 --- a/ui/src/lib/store/settingSlice.tsx +++ b/ui/src/lib/store/settingSlice.tsx @@ -8,6 +8,13 @@ export interface SettingSlice { setShowAnnotations: (b: boolean) => void; devMode?: boolean; setDevMode: (b: boolean) => void; + autoRunLayout?: boolean; + setAutoRunLayout: (b: boolean) => void; + + contextualZoomParams: Record; + contextualZoom: boolean; + setContextualZoom: (b: boolean) => void; + level2fontsize: (level: number) => number; } export const createSettingSlice: StateCreator = ( @@ -41,4 +48,50 @@ export const createSettingSlice: StateCreator = ( // also write to local storage localStorage.setItem("devMode", JSON.stringify(b)); }, + autoRunLayout: localStorage.getItem("autoRunLayout") + ? JSON.parse(localStorage.getItem("autoRunLayout")!) + : true, + setAutoRunLayout: (b: boolean) => { + set({ autoRunLayout: b }); + // also write to local storage + localStorage.setItem("autoRunLayout", JSON.stringify(b)); + }, + + contextualZoom: localStorage.getItem("contextualZoom") + ? JSON.parse(localStorage.getItem("contextualZoom")!) + : false, + setContextualZoom: (b: boolean) => { + set({ contextualZoom: b }); + // also write to local storage + localStorage.setItem("contextualZoom", JSON.stringify(b)); + }, + // TODO Make it configurable. + contextualZoomParams: { + prev: 56, + 0: 48, + 1: 32, + 2: 24, + 3: 16, + next: 8, + threshold: 16, + }, + level2fontsize: (level: number) => { + // default font size + if (!get().contextualZoom) return 16; + // when contextual zoom is on + switch (level) { + case -1: + return get().contextualZoomParams.prev; + case 0: + return get().contextualZoomParams[0]; + case 1: + return get().contextualZoomParams[1]; + case 2: + return get().contextualZoomParams[2]; + case 3: + return get().contextualZoomParams[3]; + default: + return get().contextualZoomParams.next; + } + }, }); diff --git a/ui/src/lib/utils.tsx b/ui/src/lib/utils.tsx index 6aad24b8..d68e030b 100644 --- a/ui/src/lib/utils.tsx +++ b/ui/src/lib/utils.tsx @@ -10,6 +10,29 @@ export const getAuthHeaders = () => { }; }; +export function timeDifference(current, previous) { + const msPerMinute = 60 * 1000; + const msPerHour = msPerMinute * 60; + const msPerDay = msPerHour * 24; + const msPerMonth = msPerDay * 30; + const msPerYear = msPerDay * 365; + const elapsed = current - previous; + + if (elapsed < msPerMinute) { + return Math.round(elapsed / 1000) + " seconds ago"; + } else if (elapsed < msPerHour) { + return Math.round(elapsed / msPerMinute) + " minutes ago"; + } else if (elapsed < msPerDay) { + return Math.round(elapsed / msPerHour) + " hours ago"; + } else if (elapsed < msPerMonth) { + return Math.round(elapsed / msPerDay) + " days ago"; + } else if (elapsed < msPerYear) { + return Math.round(elapsed / msPerMonth) + " months ago"; + } else { + return Math.round(elapsed / msPerYear) + " years ago"; + } +} + // pretty print the time difference export function prettyPrintTime(d) { let year = d.getUTCFullYear() - 1970; @@ -36,37 +59,4 @@ export function getUpTime(startedAt: string) { return prettyTime; } -/** - * For historical reason, the state.pod.type and DB schema pod.type are "CODE", - * "DECK", "WYSIWYG", while the node types in react-flow are "code", "scope", - * "rich". These two functions document this and handle the conversion. - * @param dbtype - * @returns - */ -export function dbtype2nodetype(dbtype: string) { - switch (dbtype) { - case "CODE": - return "code"; - case "DECK": - return "scope"; - case "WYSIWYG": - return "rich"; - default: - throw new Error(`unknown dbtype ${dbtype}`); - } -} - -export function nodetype2dbtype(nodetype: string) { - switch (nodetype) { - case "code": - return "CODE"; - case "scope": - return "DECK"; - case "rich": - return "WYSIWYG"; - default: - throw new Error(`unknown nodetype ${nodetype}`); - } -} - export const myNanoId = customAlphabet(lowercase + numbers, 20); diff --git a/ui/src/pages/login.tsx b/ui/src/pages/login.tsx index b27db739..f6303ac8 100644 --- a/ui/src/pages/login.tsx +++ b/ui/src/pages/login.tsx @@ -20,6 +20,7 @@ import { useFormik } from "formik"; import { Link as ReactLink, useNavigate } from "react-router-dom"; import { useAuth } from "../lib/auth"; +import Divider from "@mui/material/Divider"; function Copyright(props: any) { return ( @@ -43,6 +44,28 @@ const theme = createTheme(); declare var google: any; +export function GoogleSignin() { + const { handleGoogle } = useAuth(); + + useEffect(() => { + console.log("nodeenv", process.env.NODE_ENV); + let client_id = + process.env.NODE_ENV === "development" + ? process.env.REACT_APP_GOOGLE_CLIENT_ID + : window.GOOGLE_CLIENT_ID || null; + console.log("google client_id", client_id); + google.accounts.id.initialize({ + client_id, + callback: handleGoogle, + }); + google.accounts.id.renderButton( + document.getElementById("googleLoginDiv"), + { theme: "outline", size: "large" } // customization attributes + ); + }, [handleGoogle]); + return ; +} + export default function SignIn() { /* eslint-disable no-unused-vars */ const { signIn, isSignedIn, handleGoogle } = useAuth(); @@ -73,23 +96,6 @@ export default function SignIn() { }, }); - useEffect(() => { - console.log("nodeenv", process.env.NODE_ENV); - let client_id = - process.env.NODE_ENV === "development" - ? process.env.REACT_APP_GOOGLE_CLIENT_ID - : window.GOOGLE_CLIENT_ID || null; - console.log("google client_id", client_id); - google.accounts.id.initialize({ - client_id, - callback: handleGoogle, - }); - google.accounts.id.renderButton( - document.getElementById("googleLoginDiv"), - { theme: "outline", size: "large" } // customization attributes - ); - }, [handleGoogle]); - return ( @@ -102,13 +108,16 @@ export default function SignIn() { alignItems: "center", }} > - Sign in + + + Or login with email + - My projects ({repos.length}) + Projects shared with me ({repos.length}) - {repos.length > 0 ? ( @@ -406,8 +385,8 @@ function NoLogginErrorAlert() { ); } -export default function Page() { - const { me } = useMe(); + +function RepoLists() { // peiredically re-render so that the "last active time" is updated const [counter, setCounter] = useState(0); useEffect(() => { @@ -416,8 +395,27 @@ export default function Page() { }, 1000); return () => clearInterval(interval); }, [counter]); + return ( + <> + + + + ); +} + +export default function Page() { + const { me } = useMe(); + const { hasToken, loginGuest, isSignedIn } = useAuth(); + + useEffect(() => { + if (!hasToken()) { + loginGuest(); + } + }, [hasToken]); + if (!me) { - return ; + // return ; + return Loading user ..; } return ( @@ -432,11 +430,30 @@ export default function Page() { position: "relative", }} > - 👋 Welcome, {me?.firstname}! Please open or create a repository to get + Welcome, {me?.firstname}! Please open or create a repository to get started. - - + {!isSignedIn() && ( + + + Please note that you are a{" "} + + Guest + {" "} + user. Please{" "} + + Login + {" "} + or + + Signup + {" "} + to save your work. + + + + )} + ); } diff --git a/ui/src/pages/signup.tsx b/ui/src/pages/signup.tsx index fa163f08..a7a205ec 100644 --- a/ui/src/pages/signup.tsx +++ b/ui/src/pages/signup.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link as ReactLink, useNavigate } from "react-router-dom"; import Avatar from "@mui/material/Avatar"; import Button from "@mui/material/Button"; @@ -20,6 +20,8 @@ import Alert from "@mui/material/Alert"; import { useFormik } from "formik"; import { useAuth } from "../lib/auth"; +import { GoogleSignin } from "./login"; +import Divider from "@mui/material/Divider"; function Copyright(props) { return ( @@ -90,6 +92,10 @@ export default function SignUp() { Sign up + + + Or sign up with email + - - } - label="I want to receive inspiration, marketing promotions and updates via email." - /> -