Skip to content

[UI] Import Jupyter notebook #369

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion ui/src/components/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useContext,
useEffect,
memo,
ChangeEvent,
} from "react";
import * as React from "react";
import ReactFlow, {
Expand Down Expand Up @@ -48,7 +49,7 @@ import { YMap } from "yjs/dist/src/types/YMap";
import FloatingEdge from "./nodes/FloatingEdge";
import CustomConnectionLine from "./nodes/CustomConnectionLine";
import HelperLines from "./HelperLines";
import { getAbsPos } from "../lib/store/canvasSlice";
import { getAbsPos, newNodeShapeConfig } from "../lib/store/canvasSlice";

const nodeTypes = { SCOPE: ScopeNode, CODE: CodeNode, RICH: RichNode };
const edgeTypes = {
Expand Down Expand Up @@ -685,6 +686,7 @@ function CanvasImpl() {
const autoLayoutROOT = useStore(store, (state) => state.autoLayoutROOT);

const addNode = useStore(store, (state) => state.addNode);
const importIpynb = useStore(store, (state) => state.importIpynb);
const reactFlowInstance = useReactFlow();

const project = useCallback(
Expand Down Expand Up @@ -754,6 +756,8 @@ function CanvasImpl() {

const getScopeAtPos = useStore(store, (state) => state.getScopeAtPos);
const autoRunLayout = useStore(store, (state) => state.autoRunLayout);
const setAutoLayoutOnce = useStore(store, (state) => state.setAutoLayoutOnce);
const autoLayoutOnce = useStore(store, (state) => state.autoLayoutOnce);

const helperLineHorizontal = useStore(
store,
Expand All @@ -766,6 +770,51 @@ function CanvasImpl() {
const toggleMoved = useStore(store, (state) => state.toggleMoved);
const toggleClicked = useStore(store, (state) => state.toggleClicked);

const fileInputRef = useRef<HTMLInputElement>(null);

const handleItemClick = () => {
fileInputRef!.current!.click();
fileInputRef!.current!.value = "";
};

const handleFileInputChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || e.target.files.length === 0) return;
const fileName = e.target.files[0].name;
console.log("Import Jupyter Notebook: ", fileName);
const fileReader = new FileReader();
fileReader.onload = (e) => {
const fileContent =
typeof e.target!.result === "string"
? e.target!.result
: Buffer.from(e.target!.result!).toString();

const cellList = JSON.parse(String(fileContent)).cells.map((cell) => ({
cellType: cell.cell_type,
cellSource: cell.source.join(""),
}));
importIpynb(
project({ x: client.x, y: client.y }),
fileName.substring(0, fileName.length - 6),
cellList
);
setAutoLayoutOnce(true);
};
fileReader.readAsText(e.target.files[0], "UTF-8");
};

useEffect(() => {
// A BIG HACK: we run autolayout once at SOME point after ImportIpynb to
// let reactflow calculate the height of pods, then layout them properly.
if (
autoLayoutOnce &&
nodes.filter((node) => node.height === newNodeShapeConfig.height)
.length == 0
) {
autoLayoutROOT();
setAutoLayoutOnce(false);
}
}, [autoLayoutOnce, nodes]);

return (
<Box
style={{
Expand Down Expand Up @@ -887,6 +936,13 @@ function CanvasImpl() {
/>
</Box>
</ReactFlow>
<input
type="file"
accept=".ipynb"
ref={fileInputRef}
style={{ display: "none" }}
onChange={(e) => handleFileInputChange(e)}
/>
{showContextMenu && (
<CanvasContextMenu
x={points.x}
Expand All @@ -904,6 +960,10 @@ function CanvasImpl() {
addRich={() =>
addNode("RICH", project({ x: client.x, y: client.y }), parentNode)
}
handleImportClick={() => {
// handle CanvasContextMenu "import Jupyter notebook" click
handleItemClick();
}}
onShareClick={() => {
setShareOpen(true);
}}
Expand Down
9 changes: 9 additions & 0 deletions ui/src/components/CanvasContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import React, { useContext } from "react";
import CodeIcon from "@mui/icons-material/Code";
import PostAddIcon from "@mui/icons-material/PostAdd";
import NoteIcon from "@mui/icons-material/Note";
import FileUploadTwoToneIcon from "@mui/icons-material/FileUploadTwoTone";

const paneMenuStyle = (left, top) => {
return {
Expand Down Expand Up @@ -63,6 +64,14 @@ export function CanvasContextMenu(props) {
<ListItemText>New Scope</ListItemText>
</MenuItem>
)}
{!isGuest && (
<MenuItem onClick={props.handleImportClick} sx={ItemStyle}>
<ListItemIcon sx={{ color: "inherit" }}>
<FileUploadTwoToneIcon />
</ListItemIcon>
<ListItemText>Import Jupyter Notebook</ListItemText>
</MenuItem>
)}
</MenuList>
</Box>
);
Expand Down
5 changes: 3 additions & 2 deletions ui/src/components/nodes/Rich.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,7 @@ const MyEditor = ({
// content: "<p>I love <b>Remirror</b></p>",
// content: "hello world",
// content: initialContent,
content: pod.content,
content: pod.content == "" ? pod.richContent : pod.content,

// Place the cursor at the start of the document. This can also be set to
// `end`, `all` or a numbered position.
Expand All @@ -583,7 +583,8 @@ const MyEditor = ({
// `markdown` is also available when the `MarkdownExtension`
// is added to the editor.
// stringHandler: "html",
stringHandler: htmlToProsemirrorNode,
// stringHandler: htmlToProsemirrorNode,
stringHandler: "markdown",
});

let index_onChange = 0;
Expand Down
154 changes: 138 additions & 16 deletions ui/src/lib/store/canvasSlice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
} from "d3-force";
import { YMap } from "yjs/dist/src/types/YMap";

import { myNanoId } from "../utils";
import { myNanoId, level2color } from "../utils";

import {
Connection,
Expand Down Expand Up @@ -56,15 +56,17 @@ type NodeData = {
level?: number;
};

// FIXME put this into utils
const level2color = {
0: "rgba(187, 222, 251, 0.5)",
1: "rgba(144, 202, 249, 0.5)",
2: "rgba(100, 181, 246, 0.5)",
3: "rgba(66, 165, 245, 0.5)",
4: "rgba(33, 150, 243, 0.5)",
// default: "rgba(255, 255, 255, 0.2)",
default: "rgba(240,240,240,0.25)",
const newScopeNodeShapeConfig = {
width: 600,
height: 600,
};

export const newNodeShapeConfig = {
width: 300,
// NOTE for import ipynb: we need to specify some reasonable height so that
// the imported pods can be properly laid-out. 130 is a good one.
// This number is also used in Canvas.tsx (refer to "A BIG HACK" in Canvas.tsx).
height: 130,
};

/**
Expand Down Expand Up @@ -130,21 +132,25 @@ function createNewNode(type: "SCOPE" | "CODE" | "RICH", position): Node {
position,
...(type === "SCOPE"
? {
width: 600,
height: 600,
style: { backgroundColor: level2color[0], width: 600, height: 600 },
width: newScopeNodeShapeConfig.width,
height: newScopeNodeShapeConfig.height,
style: {
backgroundColor: level2color[0],
width: newScopeNodeShapeConfig.width,
height: newScopeNodeShapeConfig.height,
},
}
: {
width: 300,
width: newNodeShapeConfig.width,
// 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,
height: newNodeShapeConfig.height,
style: {
width: 300,
width: newNodeShapeConfig.width,
// It turns out that this height should not be specified to let the
// height change automatically.
//
Expand Down Expand Up @@ -288,6 +294,12 @@ export interface CanvasSlice {
parent: string
) => void;

importIpynb: (
position: XYPosition,
repoName: string,
cellList: any[]
) => void;

setNodeCharWidth: (id: string, width: number) => void;

pastingNodes?: Node[];
Expand Down Expand Up @@ -324,6 +336,8 @@ export interface CanvasSlice {
buildNode2Children: () => void;
autoLayout: (scopeId: string) => void;
autoLayoutROOT: () => void;
autoLayoutOnce: boolean;
setAutoLayoutOnce: (b: boolean) => void;
}

export const createCanvasSlice: StateCreator<MyState, [], [], CanvasSlice> = (
Expand Down Expand Up @@ -464,6 +478,114 @@ export const createCanvasSlice: StateCreator<MyState, [], [], CanvasSlice> = (
}
},

importIpynb: (position, repoName, cellList) => {
console.log("Sync imported Jupyter notebook cells.");
let nodesMap = get().ydoc.getMap<Node>("pods");
let scopeNode = createNewNode("SCOPE", position);
// parent could be "ROOT" or a SCOPE node
let parent = getScopeAt(
position.x,
position.y,
[scopeNode.id],
get().nodes,
nodesMap
);
let podParent = "ROOT";
if (parent !== undefined) {
// update scopeNode
scopeNode.parentNode = parent.id;
scopeNode.data.level = parent.data.level + 1;
podParent = parent.id;
}

scopeNode.data.name = repoName;
nodesMap.set(scopeNode.id, scopeNode);

get().addPod({
id: scopeNode.id,
name: scopeNode.data.name,
children: [],
parent: podParent,
type: scopeNode.type as "CODE" | "SCOPE" | "RICH",
lang: "python",
x: scopeNode.position.x,
y: scopeNode.position.y,
width: scopeNode.width!,
height: scopeNode.height!,
// For my local update, set dirty to true to push to DB.
dirty: true,
pending: true,
});
if (cellList.length > 0) {
for (let i = 0; i < cellList.length; i++) {
const cell = cellList[i];
let newPos = {
x: position.x + 50,
y: position.y + 100 + i * 150,
};

let node = createNewNode(
cell.cellType == "code" ? "CODE" : "RICH",
newPos
);
let podContent = cell.cellType == "code" ? cell.cellSource : "";
let podRichContent = cell.cellType == "markdown" ? cell.cellSource : "";

// move the created node to scope and configure the necessary node attributes
const posInsideScope = getNodePositionInsideScope(
node,
scopeNode,
nodesMap,
node.height!
);
const fromLevel = node?.data.level;
const toLevel = scopeNode.data.level + 1;
const fromFontSize = get().level2fontsize(fromLevel);
const toFontSize = get().level2fontsize(toLevel);
const newWidth = node.width! * (toFontSize / fromFontSize);

node.width = newWidth;
node.data.level = toLevel;
node.position = posInsideScope;
node.parentNode = scopeNode.id;

// update peer
nodesMap.set(node.id, node);

get().addPod({
id: node.id,
children: [],
parent: scopeNode.id,
type: node.type as "CODE" | "SCOPE" | "RICH",
lang: "python",
x: node.position.x,
y: node.position.y,
width: node.width!,
height: node.height!,
content: podContent,
richContent: podRichContent,
// For my local update, set dirty to true to push to DB.
dirty: true,
pending: true,
});

// update zustand & db
get().setPodGeo(
node.id,
{ parent: scopeNode.id, ...node.position },
true
);
}
}
get().adjustLevel();
get().buildNode2Children();
// Set initial width as about 30 characters.
get().setNodeCharWidth(scopeNode.id, 30);
get().updateView();
},
autoLayoutOnce: false,
setAutoLayoutOnce: (b) => set({ autoLayoutOnce: b }),

setNodeCharWidth: (id, width) => {
let nodesMap = get().ydoc.getMap<Node>("pods");
let node = nodesMap.get(id);
Expand Down
10 changes: 10 additions & 0 deletions ui/src/lib/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,13 @@ export function getUpTime(startedAt: string) {
}

export const myNanoId = customAlphabet(lowercase + numbers, 20);

export const level2color = {
0: "rgba(187, 222, 251, 0.5)",
1: "rgba(144, 202, 249, 0.5)",
2: "rgba(100, 181, 246, 0.5)",
3: "rgba(66, 165, 245, 0.5)",
4: "rgba(33, 150, 243, 0.5)",
// default: "rgba(255, 255, 255, 0.2)",
default: "rgba(240,240,240,0.25)",
};