Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[UI] Import Jupyter notebook #369

Merged
merged 6 commits into from
Jul 14, 2023
Merged
Show file tree
Hide file tree
Changes from 2 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
60 changes: 60 additions & 0 deletions 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 @@ -674,6 +675,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 @@ -743,6 +745,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 @@ -755,6 +759,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 === 130).length == 0
) {
// console.log("----- NOT 130 ---");
autoLayoutROOT();
setAutoLayoutOnce(false);
}
}, [autoLayoutOnce, nodes]);

return (
<Box
style={{
Expand Down Expand Up @@ -876,6 +925,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 @@ -893,6 +949,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
10 changes: 9 additions & 1 deletion ui/src/components/CanvasContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +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 FormatListNumberedIcon from "@mui/icons-material/FormatListNumbered";
import FileUploadTwoToneIcon from "@mui/icons-material/FileUploadTwoTone";

const paneMenuStyle = (left, top) => {
return {
Expand Down Expand Up @@ -68,6 +68,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
93 changes: 79 additions & 14 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,17 +56,6 @@ 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)",
};

/**
* Creare the temporary nodes as well as the temporary pods based on the given pod.
* @param pod
Expand Down Expand Up @@ -142,7 +131,11 @@ function createNewNode(type: "SCOPE" | "CODE" | "RICH", position): Node {
// 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,
//
// 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 MUST match the number in Canvas.tsx (refer to "A BIG HACK" in Canvas.tsx).
height: 130,
Copy link
Collaborator

Choose a reason for hiding this comment

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

This magic number 130 must be kept consistent with the 130 in Canvas.tsx. Let's store it in one global variable and access it in both files. Also, we can use some floating number to mark it special, e.g., 136.66.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Given it's a hacky way to apply autolayout, I think we can use 130 here. We need to find a better way for autolayer eventually.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Given it's a hacky way

Precisely because it is hacky, we want to avoid breaking it accidentally, by:

  • using a global variable to avoid potential inconsistency error
  • using some floating numbers to prevent a pod from being the same value.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I see you have changed to a global variable. The floating number isn't that critical (as long as it's not 120).

style: {
width: 300,
// It turns out that this height should not be specified to let the
Expand Down Expand Up @@ -288,6 +281,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 +323,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 +465,70 @@ 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);
nodesMap.set(scopeNode.id, scopeNode);
get().addPod({
id: scopeNode.id,
name: repoName,
children: [],
parent: "ROOT",
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 : "";

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,
});
get().moveIntoScope(node.id, scopeNode.id);
Copy link
Collaborator

Choose a reason for hiding this comment

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

The current moveIntoScope function is pretty heavy because it calls the three heavy functions adjustLevel(), updateView, and buildNode2Children at its end (ref). I can see it to be very slow when a repo becomes large.

Those three functions only need to be called once at the end of the ipynbImport action. So let's add a moveIntoScopeLight without those three functions and use it here.

moveIntoScopeLight() {...}
moveIntoScope() {
    moveIntoScopeLight(); 
    get().adjustLevel();
    get().updateView();
    get().buildNode2Children();
}
importIpynb() {
    ...
    for() {...
        moveIntoScopeLight()
    }
    get().adjustLevel();
    get().updateView();
    get().buildNode2Children();
    ...
}

}
}

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 Expand Up @@ -1003,7 +1068,7 @@ export const createCanvasSlice: StateCreator<MyState, [], [], CanvasSlice> = (
/**
* Use d3-force to auto layout the nodes.
*/
autoLayout: (scopeId) => {
autoLayout: async (scopeId) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This was for debugging. Not needed.

// 1. get all the nodes and edges in the scope
let nodesMap = get().ydoc.getMap<Node>("pods");
const nodes = get().nodes.filter(
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)",
};