├── packages ├── local-client │ ├── src │ │ ├── components │ │ │ ├── TextCell │ │ │ │ ├── TextEditor.module.css │ │ │ │ ├── index.tsx │ │ │ │ └── TextEditor.tsx │ │ │ ├── Layout │ │ │ │ └── Layout.tsx │ │ │ ├── AddCell │ │ │ │ ├── AddCell.module.css │ │ │ │ └── index.tsx │ │ │ ├── CodeCell │ │ │ │ ├── CodeEditor.module.css │ │ │ │ ├── Preview.css │ │ │ │ ├── Preview.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── CodeEditor.tsx │ │ │ ├── ActionBar │ │ │ │ └── index.tsx │ │ │ ├── LanguageDropdown │ │ │ │ └── index.tsx │ │ │ ├── CellsList │ │ │ │ ├── CellItem.tsx │ │ │ │ └── CellsList.tsx │ │ │ └── Resizable │ │ │ │ └── index.tsx │ │ ├── redux │ │ │ ├── cell.ts │ │ │ ├── index.ts │ │ │ ├── store.ts │ │ │ ├── payload-types │ │ │ │ └── index.ts │ │ │ └── slices │ │ │ │ ├── cellsThunks.ts │ │ │ │ ├── bundlerSlice.ts │ │ │ │ └── cellsSlice.ts │ │ ├── App.tsx │ │ ├── main.tsx │ │ ├── bundler │ │ │ ├── plugins │ │ │ │ ├── unpkg-path-plugin.ts │ │ │ │ └── fetch-plugin.ts │ │ │ └── index.ts │ │ ├── favicon.svg │ │ ├── hooks │ │ │ └── index.ts │ │ └── global.scss │ ├── index.html │ ├── tsconfig.json │ ├── vite.config.js │ ├── package.json │ └── yarn.lock ├── cli │ ├── src │ │ ├── index.ts │ │ └── commands │ │ │ └── serve │ │ │ └── index.ts │ ├── package.json │ ├── yarn.lock │ └── tsconfig.json └── local-api │ ├── package.json │ ├── src │ ├── index.ts │ └── routes │ │ └── cells.ts │ ├── tsconfig.json │ └── yarn.lock ├── .gitignore ├── lerna.json ├── todos.md ├── package.json ├── readme.md └── diagrams ├── code-process.svg └── architecture.svg /packages/local-client/src/components/TextCell/TextEditor.module.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | .DS_Store 3 | **/dist 4 | dist-ssr 5 | *.local 6 | .env 7 | **/*.log 8 | **/build -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "version": "0.2.3", 6 | "npmClient": "yarn" 7 | } 8 | -------------------------------------------------------------------------------- /todos.md: -------------------------------------------------------------------------------- 1 | enable configuration options 2 | 3 | - bulma theme 4 | - auto-execution 5 | - if import react and react-dom automatically 6 | -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { program } from "commander"; 3 | import { serveCommand } from "./commands/serve"; 4 | 5 | program.addCommand(serveCommand); 6 | 7 | program.parse(process.argv); 8 | -------------------------------------------------------------------------------- /packages/local-client/src/redux/cell.ts: -------------------------------------------------------------------------------- 1 | export type CellTypes = "code" | "text"; 2 | 3 | export type CellLanguages = "javascript" | "typescript"; 4 | 5 | export interface Cell { 6 | id: string; 7 | type: CellTypes; 8 | content: string; 9 | language?: CellLanguages; 10 | } 11 | -------------------------------------------------------------------------------- /packages/local-client/src/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "bulmaswatch/superhero/bulmaswatch.min.css"; 3 | 4 | export const Layout: React.FC = ({ children }) => { 5 | return
{children}
; 6 | }; 7 | 8 | export default Layout; 9 | -------------------------------------------------------------------------------- /packages/local-client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import CellsList from "./components/CellsList/CellsList"; 3 | 4 | const App: React.FC = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; 11 | 12 | export default App; 13 | -------------------------------------------------------------------------------- /packages/local-client/src/redux/index.ts: -------------------------------------------------------------------------------- 1 | import store from "./store"; 2 | export default store; 3 | 4 | export * from "./cell"; 5 | export { 6 | moveCell, 7 | updateCellContent, 8 | updateCellLanguage, 9 | insertCell, 10 | deleteCell, 11 | fetchCells, 12 | saveCells, 13 | } from "./slices/cellsSlice"; 14 | export { createBundle } from "./slices/bundlerSlice"; 15 | -------------------------------------------------------------------------------- /packages/local-client/src/components/TextCell/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Cell } from "../../redux"; 3 | import TextEditor from "./TextEditor"; 4 | 5 | interface TextCellProps { 6 | cell: Cell; 7 | } 8 | 9 | const TextCell: React.FC = ({ cell }) => { 10 | return ( 11 |
12 | 13 |
14 | ); 15 | }; 16 | 17 | export default TextCell; 18 | -------------------------------------------------------------------------------- /packages/local-client/src/redux/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import { cellsReducer } from "./slices/cellsSlice"; 3 | import bundlerReducer from "./slices/bundlerSlice"; 4 | 5 | export const store = configureStore({ 6 | reducer: { 7 | cells: cellsReducer, 8 | bundler: bundlerReducer, 9 | }, 10 | }); 11 | 12 | export default store; 13 | 14 | export type RootState = ReturnType; 15 | -------------------------------------------------------------------------------- /packages/local-client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | import Layout from "./components/Layout/Layout"; 5 | import { Provider } from "react-redux"; 6 | import store from "./redux"; 7 | import "./global.scss"; 8 | import dynamicImportPolyfill from 'dynamic-import-polyfill' 9 | dynamicImportPolyfill.initialize() 10 | 11 | 12 | ReactDOM.render( 13 | 14 | 15 | 16 | 17 | , 18 | document.getElementById("root") 19 | ); 20 | -------------------------------------------------------------------------------- /packages/local-client/src/components/AddCell/AddCell.module.css: -------------------------------------------------------------------------------- 1 | .add-cell { 2 | margin-top: 30px; 3 | position: relative; 4 | opacity: 0.2; 5 | transition: opacity 0.3s; 6 | } 7 | 8 | .add-cell:hover { 9 | opacity: 1; 10 | } 11 | 12 | .divider { 13 | width: 95%; 14 | left: 2.5%; 15 | right: 2.5%; 16 | position: absolute; 17 | top: 50%; 18 | bottom: 50%; 19 | border-bottom: 3px solid grey; 20 | z-index: -1; 21 | } 22 | 23 | .add-cell .add-buttons { 24 | display: flex; 25 | justify-content: center; 26 | } 27 | 28 | .add-cell .add-buttons button { 29 | margin: 0 20px; 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "js-notebook", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "https://github.com/enixam/js-notebook", 6 | "author": "qiushi ", 7 | "license": "MIT", 8 | "scripts": { 9 | "start": "lerna run start --parallel", 10 | "lerna:publish": "lerna publish --registry=https://registry.npmjs.org --force-publish", 11 | "lerna:addBuild": "lerna add @jscript-notebook/local-client --scope=javascript-notebook" 12 | }, 13 | "command": { 14 | "publish": { 15 | "registry": "https://www.npmjs.com/" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/local-client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | JS Notebook 12 | 13 | 14 |
15 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /packages/local-client/src/components/CodeCell/CodeEditor.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | position: relative; 3 | height: 100%; 4 | width: calc(100% - 10px); 5 | } 6 | 7 | .wrapper .buttons { 8 | display: flex; 9 | flex-direction: column; 10 | height: 100%; 11 | color: red; 12 | position: absolute; 13 | top: 0px; 14 | right: 5px; 15 | z-index: 20; 16 | opacity: 0; 17 | transition: opacity 0.3s; 18 | } 19 | 20 | .wrapper:hover .buttons { 21 | opacity: 1; 22 | } 23 | 24 | .buttons button { 25 | display: block; 26 | margin-bottom: 10px; 27 | width: 5rem; 28 | } 29 | 30 | .buttons .format-run { 31 | margin-top: auto; 32 | } 33 | -------------------------------------------------------------------------------- /packages/local-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": [ 5 | "DOM", 6 | "DOM.Iterable", 7 | "ESNext" 8 | ], 9 | "types": [ 10 | "vite/client" 11 | ], 12 | "allowJs": false, 13 | "skipLibCheck": false, 14 | "esModuleInterop": false, 15 | "allowSyntheticDefaultImports": true, 16 | "strict": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "module": "ESNext", 19 | "moduleResolution": "Node", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "noEmit": true, 23 | "jsx": "react-jsx", 24 | "noFallthroughCasesInSwitch": true 25 | }, 26 | "include": [ 27 | "./src" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /packages/local-api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jscript-notebook/local-api", 3 | "version": "0.2.3", 4 | "main": "dist/index.js", 5 | "types": "dist/index.d.ts", 6 | "publishConfig": { 7 | "access": "public" 8 | }, 9 | "files": [ 10 | "dist" 11 | ], 12 | "scripts": { 13 | "start": "tsc --watch --preserveWatchOutput", 14 | "prepublishOnly": "tsc" 15 | }, 16 | "license": "MIT", 17 | "devDependencies": { 18 | "@types/cors": "^2.8.10", 19 | "@types/express": "^4.17.11" 20 | }, 21 | "dependencies": { 22 | "@jscript-notebook/local-client": "^0.2.3", 23 | "cors": "^2.8.5", 24 | "express": "^4.17.1", 25 | "http-proxy-middleware": "^1.1.0" 26 | }, 27 | "gitHead": "03c0a809fc3245f27bff8fae0c7e7d56022891bf" 28 | } 29 | -------------------------------------------------------------------------------- /packages/local-client/src/redux/payload-types/index.ts: -------------------------------------------------------------------------------- 1 | import { CellTypes, CellLanguages } from "../cell"; 2 | 3 | type MoveDirection = "up" | "down"; 4 | 5 | export interface MoveCell { 6 | id: string; 7 | direction: MoveDirection; 8 | } 9 | 10 | export interface DeleteCell { 11 | id: string; 12 | } 13 | 14 | export interface InsertCell { 15 | id: string | null; 16 | type: CellTypes; 17 | } 18 | 19 | export interface UpdateCellContent { 20 | id: string; 21 | content: string; 22 | } 23 | 24 | export interface UpdateCellLanguage { 25 | id: string; 26 | language: CellLanguages; 27 | } 28 | 29 | export interface BundlerInput { 30 | id: string; 31 | input: string; 32 | hasTypescript: boolean; 33 | } 34 | 35 | export interface BundlerOutput { 36 | code: string; 37 | error: string; 38 | } 39 | -------------------------------------------------------------------------------- /packages/local-client/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import reactRefresh from "@vitejs/plugin-react-refresh"; 3 | import path from "path"; 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [ 7 | reactRefresh(), 8 | { 9 | name: 'dynamic-import-polyfill', 10 | renderDynamicImport() { 11 | return { 12 | left: '__import__(', 13 | right: ', import.meta.url)' 14 | } 15 | } 16 | } 17 | ], 18 | define: { 19 | resolve: { 20 | alias: [ 21 | { 22 | find: "@", 23 | replacement: path.resolve(__dirname, "./src"), 24 | }, 25 | ], 26 | }, 27 | }, 28 | server: { 29 | fsServe: { 30 | strict: false 31 | }, 32 | hmr: { 33 | overlay: false 34 | } 35 | }, 36 | }) 37 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "javascript-notebook", 3 | "version": "0.2.3", 4 | "license": "MIT", 5 | "bin": "dist/index.js", 6 | "files": [ 7 | "dist" 8 | ], 9 | "publishConfig": { 10 | "access": "public" 11 | }, 12 | "scripts": { 13 | "start": "tsc --watch --preserveWatchOutput", 14 | "prepublishOnly": "esbuild ./src/index.js --platform=node --outfile=./dist/index.js --bundle --minify --define:process.env.NODE_ENV='production'" 15 | }, 16 | "devDependencies": { 17 | "@jscript-notebook/local-api": "^0.2.3", 18 | "@types/chalk": "^2.2.0", 19 | "@types/node": "^14.14.37", 20 | "@types/webrtc": "^0.0.27", 21 | "chalk": "^4.1.0", 22 | "commander": "^7.2.0", 23 | "esbuild": "^0.11.6", 24 | "typescript": "^4.2.3" 25 | }, 26 | "gitHead": "03c0a809fc3245f27bff8fae0c7e7d56022891bf", 27 | "dependencies": { 28 | "@jscript-notebook/local-client": "^0.2.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/local-client/src/components/ActionBar/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useActions } from "../../hooks"; 3 | import { CellTypes } from "../../redux"; 4 | 5 | interface ActionBarProps { 6 | id: string; 7 | } 8 | 9 | const ActionBar: React.FC = ({ id }) => { 10 | const { moveCell, deleteCell, updateCellLanguage } = useActions(); 11 | 12 | return ( 13 |
14 | 17 | 20 | 23 |
24 | ); 25 | }; 26 | 27 | export default ActionBar; 28 | -------------------------------------------------------------------------------- /packages/local-client/src/components/CodeCell/Preview.css: -------------------------------------------------------------------------------- 1 | .preview-wrapper { 2 | position: relative; 3 | height: 100%; 4 | flex-grow: 1; 5 | } 6 | 7 | .preview-wrapper iframe { 8 | background-color: white; 9 | height: 100%; 10 | width: 100%; 11 | display: block; 12 | } 13 | 14 | .progress-wrapper { 15 | position: absolute; 16 | left: 0; 17 | top: 0; 18 | height: 100%; 19 | width: 100%; 20 | background-color: white; 21 | display: flex; 22 | align-items: center; 23 | padding-left: 10%; 24 | padding-right: 10%; 25 | animation: fadeIn 0.5s; 26 | } 27 | 28 | @keyframes fadeIn { 29 | 0% { 30 | opacity: 0%; 31 | } 32 | 50% { 33 | opacity: 0%; 34 | } 35 | 100% { 36 | opacity: 100%; 37 | } 38 | } 39 | 40 | .react-draggable-transparent-selection .preview-wrapper:after { 41 | content: ""; 42 | position: absolute; 43 | top: 0; 44 | right: 0; 45 | bottom: 0; 46 | left: 0; 47 | opacity: 0; 48 | } 49 | -------------------------------------------------------------------------------- /packages/local-api/src/index.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import path from "path"; 3 | import { createProxyMiddleware } from "http-proxy-middleware"; 4 | import { createCellsRouter } from "./routes/cells"; 5 | 6 | export const serve = ( 7 | port: number, 8 | filename: string, 9 | dir: string, 10 | useProxy: boolean 11 | ) => { 12 | const app = express(); 13 | 14 | const cellsRouter = createCellsRouter(filename, dir); 15 | app.use(cellsRouter); 16 | 17 | if (useProxy) { 18 | app.use( 19 | createProxyMiddleware({ 20 | target: "http://localhost:3000", 21 | ws: true, 22 | logLevel: "silent", 23 | }) 24 | ); 25 | } else { 26 | const packagePath = require.resolve( 27 | "@jscript-notebook/local-client/build/index.html" 28 | ); 29 | app.use(express.static(path.dirname(packagePath))); 30 | } 31 | 32 | return new Promise((resolve, reject) => { 33 | app.listen(port, resolve).on("error", reject); 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /packages/local-client/src/redux/slices/cellsThunks.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from "@reduxjs/toolkit"; 2 | import { Cell } from "../cell"; 3 | import axios from "axios"; 4 | import { RootState } from "../store"; 5 | 6 | export const fetchCells = createAsyncThunk< 7 | Cell[], 8 | undefined, 9 | { rejectValue: string } 10 | >("cells/fetchCells", async (_, thunkAPI) => { 11 | try { 12 | const { data }: { data: Cell[] } = await axios.get("/cells"); 13 | return data; 14 | } catch (error) { 15 | thunkAPI.rejectWithValue(error.message); 16 | } 17 | return []; 18 | }); 19 | 20 | export const saveCells = createAsyncThunk< 21 | void, 22 | undefined, 23 | { rejectValue: string; state: RootState } 24 | >("cells/saveCells", async (_, { getState, rejectWithValue }) => { 25 | const { data, order } = getState().cells; 26 | const cells = order.map((id) => data[id]); 27 | try { 28 | await axios.post("/cells", { cells }); 29 | } catch (error) { 30 | rejectWithValue(error.message); 31 | } 32 | }); 33 | -------------------------------------------------------------------------------- /packages/local-client/src/bundler/plugins/unpkg-path-plugin.ts: -------------------------------------------------------------------------------- 1 | import * as esbuild from "esbuild-wasm"; 2 | 3 | export const unpkgPathPlugin = () => { 4 | return { 5 | name: "unpkg-path-plugin", 6 | setup(build: esbuild.PluginBuild) { 7 | // handle root entry file of user input 8 | build.onResolve({ filter: /(^index\.js$)/ }, () => { 9 | return { path: "index.js", namespace: "a" }; 10 | }); 11 | 12 | // handle relative imports inside a module 13 | build.onResolve({ filter: /^\.+\// }, (args: esbuild.OnResolveArgs) => { 14 | return { 15 | path: new URL(args.path, "https://unpkg.com" + args.resolveDir + "/") 16 | .href, 17 | namespace: "a", 18 | }; 19 | }); 20 | 21 | // handle main file of a module 22 | build.onResolve({ filter: /.*/ }, async (args: esbuild.OnResolveArgs) => { 23 | return { 24 | path: `https://unpkg.com/${args.path}`, 25 | namespace: "a", 26 | }; 27 | }); 28 | }, 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /packages/local-client/src/components/LanguageDropdown/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, useState } from "react"; 2 | import { useActions } from "../../hooks"; 3 | import { CellLanguages } from "../../redux"; 4 | 5 | interface LanguageDropdownProps { 6 | id: string; 7 | initialLanguage: CellLanguages; 8 | } 9 | 10 | const LanguageDropdown: React.FC = ({ 11 | id, 12 | initialLanguage, 13 | }) => { 14 | const { updateCellLanguage } = useActions(); 15 | const [language, setLanguage] = useState(initialLanguage); 16 | 17 | const handleChange = (e: ChangeEvent) => { 18 | setLanguage(e.target.value as CellLanguages); 19 | updateCellLanguage({ id, language: e.target.value as CellLanguages }); 20 | }; 21 | 22 | return ( 23 |
24 | 28 |
29 | ); 30 | }; 31 | 32 | export default LanguageDropdown; 33 | -------------------------------------------------------------------------------- /packages/local-client/src/components/AddCell/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import classes from "./AddCell.module.css"; 3 | import { useActions } from "../../hooks"; 4 | 5 | interface AddCellProps { 6 | prevCellId: string | null; 7 | } 8 | 9 | const AddCell: React.FC = ({ prevCellId }) => { 10 | const { insertCell } = useActions(); 11 | return ( 12 |
13 |
14 | 21 | 28 |
29 |
30 |
31 | ); 32 | }; 33 | 34 | export default AddCell; 35 | -------------------------------------------------------------------------------- /packages/local-client/src/bundler/index.ts: -------------------------------------------------------------------------------- 1 | import * as esbuild from "esbuild-wasm"; 2 | import { unpkgPathPlugin } from "./plugins/unpkg-path-plugin"; 3 | import { fetchPlugin } from "./plugins/fetch-plugin"; 4 | 5 | let hasService = false; 6 | 7 | interface BundledResult { 8 | code: string; 9 | error: string; 10 | } 11 | 12 | const esBundle = async ( 13 | input: string, 14 | hasTypescript: boolean 15 | ): Promise => { 16 | if (!hasService) { 17 | await esbuild.initialize({ 18 | worker: true, 19 | wasmURL: "https://unpkg.com/esbuild-wasm@0.11.0/esbuild.wasm", 20 | }); 21 | hasService = true; 22 | } 23 | try { 24 | const result = await esbuild.build({ 25 | entryPoints: ["index.js"], 26 | bundle: true, 27 | write: false, 28 | plugins: [unpkgPathPlugin(), fetchPlugin(input, hasTypescript)], 29 | define: { 30 | global: "window", 31 | }, 32 | }); 33 | return { 34 | code: result.outputFiles[0].text, 35 | error: "", 36 | }; 37 | } catch (error) { 38 | return { 39 | code: "", 40 | error: error.message, 41 | }; 42 | } 43 | }; 44 | 45 | export default esBundle; 46 | -------------------------------------------------------------------------------- /packages/local-api/src/routes/cells.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import fs from "fs/promises"; 3 | import path from "path"; 4 | 5 | interface Cell { 6 | id: string; 7 | type: "text" | "code"; 8 | content: string; 9 | } 10 | 11 | export const createCellsRouter = (filename: string, dir: string) => { 12 | const router = express.Router(); 13 | router.use(express.json()); 14 | const fullPath = path.join(dir, filename); 15 | 16 | router.get("/cells", async (req, res) => { 17 | console.log("fetching cells ..."); 18 | try { 19 | const result = await fs.readFile(fullPath, { encoding: "utf-8" }); 20 | const payload = JSON.parse(result); 21 | res.send(payload); 22 | } catch (error) { 23 | if (error.code === "ENOENT") { 24 | await fs.writeFile(fullPath, "[]", "utf-8"); 25 | res.send([]); 26 | } else { 27 | throw error; 28 | } 29 | } 30 | }); 31 | 32 | router.post("/cells", async (req, res) => { 33 | console.log("saving cells ..."); 34 | 35 | const { cells }: { cells: Cell[] } = req.body; 36 | 37 | await fs.writeFile(fullPath, JSON.stringify(cells), "utf-8"); 38 | 39 | res.send({ status: "ok" }); 40 | }); 41 | 42 | return router; 43 | }; 44 | -------------------------------------------------------------------------------- /packages/local-client/src/components/CellsList/CellItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Cell } from "../../redux"; 3 | import TextCell from "../TextCell"; 4 | import CodeCell from "../CodeCell"; 5 | import ActionBar from "../ActionBar"; 6 | import LanguageDropdown from "../LanguageDropdown"; 7 | 8 | interface CellItemProps { 9 | cell: Cell; 10 | hasTypescript: boolean; 11 | } 12 | 13 | const CellItem: React.FC = ({ cell, hasTypescript }) => { 14 | return ( 15 | <> 16 | {cell.type === "code" && ( 17 |
18 |
19 |
20 | 24 | 25 |
26 | 27 |
28 |
29 | )} 30 | {cell.type === "text" && ( 31 |
32 |
33 | 34 | 35 |
36 |
37 | )} 38 | 39 | ); 40 | }; 41 | export default React.memo(CellItem); 42 | -------------------------------------------------------------------------------- /packages/local-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jscript-notebook/local-client", 3 | "version": "0.2.3", 4 | "files": [ 5 | "build" 6 | ], 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "scripts": { 11 | "dev": "vite", 12 | "start": "vite", 13 | "build": "tsc && vite build", 14 | "serve": "vite preview" 15 | }, 16 | "devDependencies": { 17 | "@monaco-editor/react": "^4.1.0", 18 | "@reduxjs/toolkit": "^1.5.1", 19 | "@types/prettier": "^2.2.3", 20 | "@types/react": "^17.0.0", 21 | "@types/react-dom": "^17.0.0", 22 | "@types/react-redux": "^7.1.16", 23 | "@types/react-resizable": "^1.7.2", 24 | "@uiw/react-md-editor": "2.1.1", 25 | "@vitejs/plugin-react-refresh": "^1.3.1", 26 | "axios": "^0.21.1", 27 | "bulmaswatch": "^0.8.1", 28 | "esbuild-wasm": "0.11.0", 29 | "localforage": "^1.9.0", 30 | "monaco-editor": "^0.23.0", 31 | "prettier": "^2.2.1", 32 | "react": "^17.0.0", 33 | "react-dom": "^17.0.0", 34 | "react-redux": "^7.2.3", 35 | "react-resizable": "^1.11.1", 36 | "redux": "^4.0.5", 37 | "sass": "^1.32.8", 38 | "typescript": "^4.1.2", 39 | "vite": "^2.3.0" 40 | }, 41 | "license": "MIT", 42 | "gitHead": "03c0a809fc3245f27bff8fae0c7e7d56022891bf", 43 | "dependencies": { 44 | "dynamic-import-polyfill": "^0.1.1" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/cli/src/commands/serve/index.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander"; 2 | import { serve } from "@jscript-notebook/local-api"; 3 | import path from "path"; 4 | import chalk from "chalk"; 5 | 6 | interface Options { 7 | port: string; 8 | } 9 | // not used for now 10 | const isProduction = process.env.NODE_ENV === "production"; 11 | 12 | const log = console.log; 13 | 14 | const serveAction = async (filename = "notebook.js", { port }: Options) => { 15 | const dir = path.join(process.cwd(), path.dirname(filename)); 16 | try { 17 | await serve(parseFloat(port), path.basename(filename), dir, false); 18 | log( 19 | `Notebook live at ${chalk.inverse( 20 | `http://localhost:${port}` 21 | )} \n opened file ${chalk.underline( 22 | `${path.basename(filename)}` 23 | )} \n browse source code at ${chalk.green( 24 | `https://github.com/enixam/js-notebook` 25 | )}` 26 | ); 27 | } catch (error) { 28 | if (error.code === "EADDRINUSE") { 29 | log( 30 | chalk.red( 31 | `${port} already in use, try using a different port via the --port option` 32 | ) 33 | ); 34 | } else { 35 | log(chalk.red(error)); 36 | } 37 | process.exit(1); 38 | } 39 | }; 40 | 41 | export const serveCommand = new Command() 42 | .command("serve [filename]") 43 | .option("-p, --port ", "port to run server on", "3001") 44 | .description("open a file for editing") 45 | .action(serveAction); 46 | -------------------------------------------------------------------------------- /packages/local-client/src/components/TextCell/TextEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import MDEditor from "@uiw/react-md-editor"; 3 | import { Cell } from "../../redux"; 4 | import { useActions } from "../../hooks"; 5 | 6 | interface TextEditorProps { 7 | cell: Cell; 8 | } 9 | 10 | const TextEditor: React.FC = ({ cell }) => { 11 | const { updateCellContent } = useActions(); 12 | const [editMode, setEditMode] = useState(false); 13 | const mdEditor = useRef(); 14 | 15 | useEffect(() => { 16 | const listener = (event: MouseEvent) => { 17 | if ( 18 | mdEditor.current && 19 | event.target && 20 | mdEditor.current.contains(event.target) 21 | ) { 22 | setEditMode(true); 23 | } else { 24 | setEditMode(false); 25 | } 26 | }; 27 | document.addEventListener("click", listener, { capture: true }); 28 | return () => 29 | document.removeEventListener("click", listener, { capture: true }); 30 | }, []); 31 | 32 | return ( 33 |
34 | {!editMode && ( 35 | 36 | )} 37 | {editMode && ( 38 | updateCellContent({ id: cell.id, content: e || "" })} 41 | /> 42 | )} 43 |
44 | ); 45 | }; 46 | 47 | export default TextEditor; 48 | -------------------------------------------------------------------------------- /packages/local-client/src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /packages/local-client/src/components/CellsList/CellsList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import CellItem from "./CellItem"; 3 | import { useDispatch, useSelector } from "../../hooks"; 4 | import { Cell, fetchCells, saveCells } from "../../redux"; 5 | import AddCell from "../AddCell"; 6 | 7 | const CellsList: React.FC = () => { 8 | const dispatch = useDispatch(); 9 | 10 | // fetch cells from file 11 | useEffect(() => { 12 | dispatch(fetchCells()); 13 | }, []); 14 | 15 | // save cells to file every 1 minute 16 | useEffect(() => { 17 | const interval = setInterval(() => { 18 | dispatch(saveCells()); 19 | }, 60000); 20 | 21 | return () => clearInterval(interval); 22 | }, []); 23 | 24 | const { cellsData, order, hasTypescript } = useSelector(({ cells }) => { 25 | let { data, order } = cells; 26 | const cellsData = order.map((id) => data[id]); 27 | const hasTypescript = 28 | cellsData.filter((cell) => cell.language === "typescript").length > 0; 29 | return { cellsData, order, hasTypescript }; 30 | }); 31 | 32 | const cells = cellsData.map((cell) => { 33 | return ( 34 |
35 | 36 | 37 |
38 | ); 39 | }); 40 | 41 | return ( 42 |
43 | {order.length === 0 && ( 44 |
45 | 46 |
47 | )} 48 | {cells} 49 |
50 | ); 51 | }; 52 | 53 | export default CellsList; 54 | -------------------------------------------------------------------------------- /packages/local-client/src/redux/slices/bundlerSlice.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; 2 | import esBundle from "../../bundler"; 3 | import { BundlerOutput, BundlerInput } from "../payload-types"; 4 | 5 | interface BundlerState { 6 | [key: string]: 7 | | { 8 | loading: boolean; 9 | code: string; 10 | error: string; 11 | } 12 | | undefined; 13 | } 14 | 15 | interface RejectValue { 16 | id: string; 17 | error: string; 18 | } 19 | 20 | const initialState: BundlerState = {}; 21 | 22 | export const createBundle = createAsyncThunk< 23 | BundlerOutput, 24 | BundlerInput, 25 | { rejectValue: RejectValue } 26 | >("bundler/create", async (payload, thunkAPI) => { 27 | const { id, input, hasTypescript } = payload; 28 | const { code, error } = await esBundle(input, hasTypescript); 29 | if (code === "" && error !== "") { 30 | thunkAPI.rejectWithValue({ id, error }); 31 | } else if (code !== "" && !error) { 32 | return { code, error }; 33 | } 34 | return { code, error }; 35 | }); 36 | 37 | const bundlerSlice = createSlice({ 38 | name: "bundler", 39 | initialState, 40 | reducers: {}, 41 | extraReducers: (builder) => { 42 | builder.addCase(createBundle.pending, (state, { meta }) => { 43 | const id = meta.arg.id; 44 | state[id] = { 45 | loading: true, 46 | code: "", 47 | error: "", 48 | }; 49 | }); 50 | 51 | builder.addCase(createBundle.fulfilled, (state, { payload, meta }) => { 52 | const { code } = payload; 53 | const id = meta.arg.id; 54 | state[id] = { 55 | loading: false, 56 | code, 57 | error: "", 58 | }; 59 | }); 60 | 61 | builder.addCase(createBundle.rejected, (state, { payload }) => { 62 | if (payload) { 63 | const { id, error } = payload; 64 | state[id] = { 65 | loading: false, 66 | error, 67 | code: "", 68 | }; 69 | } 70 | }); 71 | }, 72 | }); 73 | 74 | export default bundlerSlice.reducer; 75 | -------------------------------------------------------------------------------- /packages/local-client/src/components/Resizable/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { ResizableBox, ResizableBoxProps } from "react-resizable"; 3 | 4 | interface ResizableProps { 5 | direction: "horizontal" | "vertical"; 6 | } 7 | 8 | interface ResizableConfig { 9 | horizontal: ResizableBoxProps; 10 | vertical: ResizableBoxProps; 11 | } 12 | 13 | const Resizable: React.FC = ({ direction, children }) => { 14 | const [innerHeight, setInnerHeight] = useState(window.innerHeight); 15 | const [innerWidth, setInnerWidth] = useState(window.innerWidth); 16 | // prevent width jump when resizing the window 17 | const [width, setWidth] = useState(window.innerWidth * 0.75); 18 | 19 | useEffect(() => { 20 | let timer: number | undefined; 21 | const listener = () => { 22 | if (timer) { 23 | clearTimeout(timer); 24 | } 25 | timer = setTimeout(() => { 26 | setInnerHeight(window.innerHeight); 27 | setInnerWidth(window.innerWidth); 28 | // need to update width if window innerWidth is too small 29 | if (window.innerWidth * 0.75 < width) { 30 | setWidth(window.innerWidth * 0.75); 31 | } 32 | }, 100); 33 | }; 34 | window.addEventListener("resize", listener); 35 | return () => { 36 | window.removeEventListener("resize", listener); 37 | }; 38 | }, [width]); 39 | 40 | let resizableConfig: ResizableConfig = { 41 | horizontal: { 42 | className: "resize-horizontal", 43 | maxConstraints: [innerWidth, Infinity], 44 | minConstraints: [innerWidth * 0.05, Infinity], 45 | height: Infinity, 46 | width: width * 0.75, 47 | resizeHandles: ["e"], 48 | onResizeStop: (_, data) => { 49 | setWidth(data.size.width); 50 | }, 51 | }, 52 | vertical: { 53 | className: "resize-vertical", 54 | maxConstraints: [Infinity, innerHeight * 0.8], 55 | minConstraints: [Infinity, 25], 56 | width: Infinity, 57 | height: 450, 58 | resizeHandles: ["s"], 59 | }, 60 | }; 61 | 62 | return ( 63 | {children} 64 | ); 65 | }; 66 | 67 | Resizable.defaultProps = { 68 | direction: "vertical", 69 | }; 70 | 71 | export default Resizable; 72 | -------------------------------------------------------------------------------- /packages/local-client/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import store, { RootState } from "../redux/store"; 2 | import { 3 | TypedUseSelectorHook, 4 | useDispatch as _useDispatch, 5 | useSelector as _useSelector, 6 | } from "react-redux"; 7 | import { bindActionCreators } from "redux"; 8 | import { 9 | moveCell, 10 | updateCellContent, 11 | updateCellLanguage, 12 | insertCell, 13 | deleteCell, 14 | } from "../redux"; 15 | 16 | type AppDispatch = typeof store.dispatch; 17 | 18 | // Export typed version of useDispatch and useSelector 19 | export const useDispatch = () => _useDispatch(); 20 | export const useSelector: TypedUseSelectorHook = _useSelector; 21 | 22 | // action creators 23 | const actionCreators = { 24 | moveCell, 25 | updateCellContent, 26 | updateCellLanguage, 27 | insertCell, 28 | deleteCell, 29 | }; 30 | export const useActions = () => { 31 | const dispatch = useDispatch(); 32 | return bindActionCreators(actionCreators, dispatch); 33 | }; 34 | 35 | export const useCumulativeCode = (id: string) => { 36 | return useSelector((state) => { 37 | const defineShow = ` 38 | show = (value, concat = false) => { 39 | const root = document.querySelector("#root") 40 | if (typeof value === "object") { 41 | if (value.$$typeof && value.props) { 42 | if (!concat) { 43 | ReactDOM.render(value, root) 44 | } 45 | } else { 46 | !concat ? root.innerHTML = JSON.stringify(value) : root.innerHTML = root.innerHTML + '
' + JSON.stringify(value) 47 | } 48 | } else { 49 | !concat ? root.innerHTML = value : root.innerHTML = root.innerHTML + '
' + value 50 | } 51 | } 52 | `; 53 | const { data, order } = state.cells; 54 | const orderedCodeCells = order 55 | .map((id) => data[id]) 56 | .filter((c) => c.type === "code"); 57 | const cumulativeCodeArray = ["let show;"]; 58 | for (let c of orderedCodeCells) { 59 | if (c.id !== id) { 60 | cumulativeCodeArray.push("show = () => {}" + "\n" + c.content); 61 | } else if (c.id === id) { 62 | cumulativeCodeArray.push(defineShow + "\n" + c.content); 63 | break; 64 | } 65 | } 66 | 67 | const cumulativeCode = cumulativeCodeArray.reduce((all, prev) => { 68 | return all + "\n" + prev; 69 | }, ""); 70 | 71 | return cumulativeCode; 72 | }); 73 | }; 74 | -------------------------------------------------------------------------------- /packages/local-client/src/components/CodeCell/Preview.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from "react"; 2 | import { useSelector } from "../../hooks"; 3 | import "./Preview.css"; 4 | 5 | interface PreviewProps { 6 | id: string; 7 | } 8 | 9 | const html = ` 10 | 11 | 12 | 13 |
14 | 17 | 46 | 47 | 48 | 49 | `; 50 | 51 | const Preview: React.FC = ({ id }) => { 52 | const iframe = useRef(); 53 | const { code, error, loading } = useSelector( 54 | (state) => state.bundler[id] 55 | ) || { 56 | code: "", 57 | error: "", 58 | loading: false, 59 | }; 60 | 61 | useEffect(() => { 62 | iframe.current.contentWindow.postMessage({ code, error }, "*"); 63 | }, [code, error]); 64 | return ( 65 |
66 | {loading && ( 67 |
68 | 69 | Loading 70 | 71 |
72 | )} 73 |