├── jsconfig.json ├── next.config.js ├── postcss.config.js ├── README.md ├── app ├── page.js ├── [id] │ └── page.js ├── (helpers) │ └── proxy │ │ └── route.js ├── layout.js └── globals.scss ├── lib ├── hooks │ ├── debounce.js │ └── useLocalStorage.ts ├── api │ └── notebooks.js └── bundler │ ├── plugins │ ├── unpkg-path-plugin.ts │ └── fetch-plugin.ts │ └── index.ts ├── .gitignore ├── tailwind.config.js ├── components ├── block │ ├── CodeBlockNode.js │ ├── LogLine.js │ └── Preview.js ├── editor │ ├── defaultContent.js │ └── SlashCommand.tsx ├── Editor.js ├── Draft.js └── IncludableLabel.js ├── package.json └── yarn.lock /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./*"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JS Notebook 2 | 3 | Play around with Javascript in your browser and share notebooks. 4 | 5 | [**jsnotebook.dev**](https://jsnotebook.dev) 6 | -------------------------------------------------------------------------------- /app/page.js: -------------------------------------------------------------------------------- 1 | import Draft from "@/components/Draft"; 2 | import { defaultContent } from "@/components/editor/defaultContent"; 3 | 4 | export default function Home() { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /lib/hooks/debounce.js: -------------------------------------------------------------------------------- 1 | function debounce(func, timeout = 100) { 2 | let timer; 3 | return (...args) => { 4 | clearTimeout(timer); 5 | timer = setTimeout(() => { 6 | func.apply(this, args); 7 | }, timeout); 8 | }; 9 | } 10 | 11 | export default debounce; 12 | -------------------------------------------------------------------------------- /app/[id]/page.js: -------------------------------------------------------------------------------- 1 | import Draft from "@/components/Draft"; 2 | import { notFound } from "next/navigation"; 3 | 4 | export default async function Notebook({ params: { id } }) { 5 | let notebook; 6 | try { 7 | const result = await fetch( 8 | `https://schof.link/${id}?d=${new Date().valueOf()}` 9 | ); 10 | notebook = await result.json(); 11 | } catch (e) { 12 | return notFound(); 13 | } 14 | 15 | return ; 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /app/(helpers)/proxy/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import axios from "axios"; 3 | 4 | export const GET = async (request) => { 5 | const { searchParams } = new URL(request.url); 6 | const url = searchParams.get("url"); 7 | 8 | const { data, headers: axiosHeaders, status } = await axios.get(url); 9 | 10 | const headers = new Headers(); 11 | for (const [key, value] of Object.entries(axiosHeaders)) { 12 | if(key === 'content-encoding' || key === 'connection') continue; 13 | headers.set(key, value); 14 | } 15 | 16 | return new NextResponse(data, { headers, status }); 17 | }; 18 | -------------------------------------------------------------------------------- /app/layout.js: -------------------------------------------------------------------------------- 1 | import IncludableLabel from "@/components/IncludableLabel"; 2 | import "./globals.scss"; 3 | import { Inter } from "next/font/google"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata = { 8 | title: "JS Notebook", 9 | description: 10 | "Play around with Javascript in your browser and share notebooks.", 11 | }; 12 | 13 | export default function RootLayout({ children }) { 14 | return ( 15 | 16 | 17 | {children} 18 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 5 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 7 | ], 8 | theme: { 9 | extend: { 10 | backgroundImage: { 11 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 12 | "gradient-conic": 13 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 14 | }, 15 | }, 16 | }, 17 | plugins: [require("@tailwindcss/typography"), require("@tailwindcss/forms")], 18 | }; 19 | -------------------------------------------------------------------------------- /lib/hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | const useLocalStorage = ( 4 | key: string, 5 | initialValue: T 6 | // eslint-disable-next-line no-unused-vars 7 | ): [T, (value: T) => void] => { 8 | const [storedValue, setStoredValue] = useState(initialValue); 9 | 10 | useEffect(() => { 11 | // Retrieve from localStorage 12 | const item = window.localStorage.getItem(key); 13 | if (item) { 14 | setStoredValue(JSON.parse(item)); 15 | } 16 | }, [key]); 17 | 18 | const setValue = (value: T) => { 19 | // Save state 20 | setStoredValue(value); 21 | // Save to localStorage 22 | window.localStorage.setItem(key, JSON.stringify(value)); 23 | }; 24 | return [storedValue, setValue]; 25 | }; 26 | 27 | export default useLocalStorage; 28 | -------------------------------------------------------------------------------- /lib/api/notebooks.js: -------------------------------------------------------------------------------- 1 | export const saveNotebook = async (notebook) => { 2 | const result = await fetch( 3 | `https://schof.link/api/get-url?${new URLSearchParams({ 4 | filename: "notebook.json", 5 | contentType: "application/json", 6 | editable: "true", 7 | id: notebook.id || undefined, 8 | unique: new Date().valueOf(), 9 | })}` 10 | ); 11 | const { key, url } = await result.json(); 12 | 13 | await fetch(url, { 14 | method: "PUT", 15 | body: JSON.stringify({ 16 | ...notebook, 17 | id: key, 18 | createdAt: notebook.createdAt || new Date().toISOString(), 19 | updatedAt: new Date().toISOString(), 20 | }), 21 | headers: { 22 | "Content-Type": "application/json", 23 | "Content-Disposition": `inline; filename="notebook.json"`, 24 | }, 25 | }); 26 | 27 | return key; 28 | }; 29 | -------------------------------------------------------------------------------- /components/block/CodeBlockNode.js: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { NodeViewContent, NodeViewWrapper } from "@tiptap/react"; 3 | 4 | import Preview from "./Preview"; 5 | 6 | const getText = (node) => { 7 | let result = ""; 8 | node.descendants((n) => { 9 | if (n.type.name === "text") { 10 | result += n.text; 11 | } 12 | }); 13 | return result; 14 | }; 15 | 16 | export default ({ node, updateAttributes }) => { 17 | const nodeText = useMemo(() => getText(node), [node]); 18 | 19 | return ( 20 | 21 |
22 |         
23 |       
24 |
25 | 30 |
31 |
32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /lib/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: /(^input\.ts$)/ }, () => { 9 | return { path: "input.ts", namespace: "app" }; 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: "app", 18 | }; 19 | }); 20 | 21 | // handle main file of a module 22 | build.onResolve({ filter: /.*/ }, async (args: esbuild.OnResolveArgs) => { 23 | const isUrl = args.path.startsWith('https://') || args.path.startsWith('http://'); 24 | return { 25 | path: isUrl ? args.path : `https://unpkg.com/${args.path}`, 26 | namespace: "app", 27 | }; 28 | }); 29 | }, 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notebook", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@tiptap/core": "^2.1.7", 13 | "@tiptap/extension-code-block-lowlight": "^2.1.7", 14 | "@tiptap/extension-document": "^2.1.7", 15 | "@tiptap/extension-highlight": "^2.1.7", 16 | "@tiptap/extension-link": "^2.1.7", 17 | "@tiptap/extension-placeholder": "^2.1.7", 18 | "@tiptap/extension-typography": "^2.1.7", 19 | "@tiptap/pm": "^2.1.7", 20 | "@tiptap/react": "^2.1.7", 21 | "@tiptap/starter-kit": "^2.1.7", 22 | "@tiptap/suggestion": "^2.1.7", 23 | "autoprefixer": "10.4.15", 24 | "axios": "^1.5.0", 25 | "crc-32": "^1.2.2", 26 | "esbuild-wasm": "0.19.2", 27 | "highlight.js": "^11.8.0", 28 | "localforage": "^1.10.0", 29 | "lowlight": "^2.9.0", 30 | "lucide-react": "^0.269.0", 31 | "next": "13.4.19", 32 | "postcss": "8.4.28", 33 | "react": "18.2.0", 34 | "react-dom": "18.2.0", 35 | "sass": "^1.66.1", 36 | "sonner": "^0.7.0", 37 | "tailwindcss": "3.3.3" 38 | }, 39 | "devDependencies": { 40 | "@tailwindcss/forms": "^0.5.5", 41 | "@tailwindcss/typography": "^0.5.9" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /components/block/LogLine.js: -------------------------------------------------------------------------------- 1 | import { 2 | AlertTriangle, 3 | ArrowRight, 4 | CheckCircle2, 5 | InfoIcon, 6 | XOctagon, 7 | } from "lucide-react"; 8 | 9 | const LogLine = ({ type, args }) => { 10 | let icon = null; 11 | switch (type) { 12 | case "ERR": 13 | icon = ; 14 | break; 15 | case "INFO": 16 | icon = ; 17 | break; 18 | case "WARN": 19 | icon = ; 20 | break; 21 | case "LOG": 22 | case "RESULT": 23 | case "HTML": 24 | icon = ; 25 | break; 26 | } 27 | 28 | let output = args.join(" "); 29 | if (type === "HTML") { 30 | output =
; 31 | } 32 | 33 | if (type === "TIME") { 34 | return ( 35 |
36 | 37 | Finished running in {output}ms 38 |
39 | ); 40 | } 41 | 42 | return ( 43 |
44 | {icon}
{output}
45 |
46 | ); 47 | }; 48 | 49 | export default LogLine; 50 | -------------------------------------------------------------------------------- /app/globals.scss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .tiptap { 6 | outline: none; 7 | width: 100%; 8 | min-height: 50vh; 9 | @apply p-12 md:p-24 pt-4 md:pt-6; 10 | 11 | .is-empty::before { 12 | content: attr(data-placeholder); 13 | float: left; 14 | color: #ced4da; 15 | pointer-events: none; 16 | height: 0; 17 | } 18 | 19 | pre { 20 | .hljs-comment, 21 | .hljs-quote { 22 | @apply text-gray-400; 23 | } 24 | 25 | .hljs-variable, 26 | .hljs-template-variable, 27 | .hljs-attribute, 28 | .hljs-tag, 29 | .hljs-name, 30 | .hljs-regexp, 31 | .hljs-link, 32 | .hljs-name, 33 | .hljs-selector-id, 34 | .hljs-selector-class { 35 | color: #f98181; 36 | } 37 | 38 | .hljs-number, 39 | .hljs-meta, 40 | .hljs-built_in, 41 | .hljs-builtin-name, 42 | .hljs-literal, 43 | .hljs-type, 44 | .hljs-params { 45 | color: #fbbc88; 46 | } 47 | 48 | .hljs-string, 49 | .hljs-symbol, 50 | .hljs-bullet { 51 | color: #b9f18d; 52 | } 53 | 54 | .hljs-title, 55 | .hljs-section { 56 | color: #faf594; 57 | } 58 | 59 | .hljs-keyword, 60 | .hljs-selector-tag { 61 | color: #70cff8; 62 | } 63 | 64 | .hljs-emphasis { 65 | font-style: italic; 66 | } 67 | 68 | .hljs-strong { 69 | font-weight: 700; 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /lib/bundler/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as esbuild from "esbuild-wasm"; 4 | 5 | import { unpkgPathPlugin } from "./plugins/unpkg-path-plugin"; 6 | import { fetchPlugin } from "./plugins/fetch-plugin"; 7 | 8 | interface BundledResult { 9 | output: string; 10 | error: string; 11 | } 12 | 13 | let loaded = false; 14 | let isLoading = false; 15 | 16 | export const loadEsbuild = async () => { 17 | if (loaded) { 18 | return; 19 | } 20 | 21 | if (isLoading) { 22 | return new Promise((resolve) => { 23 | const interval = setInterval(() => { 24 | if (!isLoading) { 25 | clearInterval(interval); 26 | resolve(""); 27 | } 28 | }, 100); 29 | }); 30 | } 31 | 32 | isLoading = true; 33 | 34 | try { 35 | await esbuild.initialize({ 36 | worker: true, 37 | wasmURL: "https://unpkg.com/esbuild-wasm@0.19.2/esbuild.wasm", 38 | }); 39 | loaded = true; 40 | } catch (error) { 41 | console.log(error); 42 | } 43 | isLoading = false; 44 | }; 45 | 46 | const esBundle = async ( 47 | input: string, 48 | hasTypescript: boolean 49 | ): Promise => { 50 | await loadEsbuild(); 51 | try { 52 | const result = await esbuild.build({ 53 | entryPoints: ["input.ts"], 54 | bundle: true, 55 | minify: false, 56 | format: "esm", 57 | platform: "node", 58 | write: false, 59 | plugins: [unpkgPathPlugin(), fetchPlugin(input)], 60 | define: { 61 | global: "window", 62 | }, 63 | }); 64 | return { 65 | output: result.outputFiles[0].text, 66 | error: "", 67 | }; 68 | } catch (error) { 69 | return { 70 | output: "", 71 | error: error.message, 72 | }; 73 | } 74 | }; 75 | 76 | export default esBundle; 77 | -------------------------------------------------------------------------------- /components/editor/defaultContent.js: -------------------------------------------------------------------------------- 1 | export const defaultContent = 2 | '

There is a new tool in town to run Javascript in your browser.

It compiles your code using Esbuild (in the browser, with WASM!), downloads any dependencies from Unpkg, and runs the code in a sandbox environment. That makes it the perfect way to quickly test some JS behaviour, and try out new things!

console.log(\'test\');

Let’s play with dependencies!

const _ = require(\'lodash\');\nconst numbers = [1, 2, 3, 4, 5];\n\n// Using lodash, which we dynamically load from a CDN!\nconst doubledNumbers = _.map(numbers, (number) => number * 2);\nconsole.log(doubledNumbers);

That’s very simple, but how about this? What changes do you need to make to turn this into a real FizzBuzz exercise?

let i = 1;\nwhile (i < 10) {\n  if (i % 3 === 0) {\n    console.log("Fizz");\n  } else if (i % 5 === 0) {\n    console.log("Buzz");\n  } else {\n    console.log(i);\n  }\n  i++;\n}

Now it’s your turn! Go here to start your own notebook.


Author’s notes:

  • Infinite loops are very easy to achieve. Remove the i++ line above, lots of fun 😨

  • We need more docs on how deps are loaded, and ways to output HTML and graphs

  • Some clean loading states would be nice!

'; 3 | -------------------------------------------------------------------------------- /lib/bundler/plugins/fetch-plugin.ts: -------------------------------------------------------------------------------- 1 | import * as esbuild from "esbuild-wasm"; 2 | import axios from "axios"; 3 | 4 | import localForage from "localforage"; 5 | 6 | const fileCache = localForage.createInstance({ 7 | name: "file-cache", 8 | }); 9 | 10 | const fetchViaProxy = async (url: string) => { 11 | url = `/proxy?url=${encodeURIComponent(url)}`; 12 | return axios.get(url); 13 | }; 14 | 15 | export const fetchPlugin = (input: string) => { 16 | return { 17 | name: "fetch-plugin", 18 | setup(build: esbuild.PluginBuild) { 19 | // handle root user input code 20 | build.onLoad({ filter: /^input\.ts$/ }, () => { 21 | return { 22 | loader: "tsx", 23 | contents: input, 24 | }; 25 | }); 26 | 27 | // if not cached, carry on to the reamining load functions 28 | build.onLoad({ filter: /.*/ }, async (args: esbuild.OnLoadArgs) => { 29 | const cachedResult = await fileCache.getItem( 30 | args.path 31 | ); 32 | if (cachedResult) { 33 | return cachedResult; 34 | } 35 | }); 36 | 37 | // handle css files 38 | build.onLoad({ filter: /\.css$/ }, async (args: esbuild.OnLoadArgs) => { 39 | const { data, request } = await fetchViaProxy(args.path); 40 | const contents = ` 41 | const style = document.createElement('style'); 42 | style.innerText = ${JSON.stringify(data)}; 43 | document.head.appendChild(style); 44 | `; 45 | 46 | const result: esbuild.OnLoadResult = { 47 | loader: "jsx", 48 | contents, 49 | resolveDir: new URL("./", request.responseURL).pathname, 50 | }; 51 | await fileCache.setItem(args.path, result); 52 | return result; 53 | }); 54 | 55 | // handle js and jsx files 56 | build.onLoad({ filter: /.*/ }, async (args: esbuild.OnLoadArgs) => { 57 | const { 58 | data: contents, 59 | headers, 60 | request, 61 | } = await fetchViaProxy(args.path); 62 | const contentType = 63 | headers["content-type"] || "application/octet-stream"; 64 | let loader: esbuild.Loader = "js"; 65 | if (args.path.includes(".ts")) loader = "ts"; 66 | if (args.path.includes(".tsx")) loader = "tsx"; 67 | if (args.path.includes(".jsx")) loader = "jsx"; 68 | if (contentType.includes("text/")) loader = "text"; 69 | const result: esbuild.OnLoadResult = { 70 | loader, 71 | contents, 72 | resolveDir: new URL("./", request.responseURL).pathname, 73 | }; 74 | await fileCache.setItem(args.path, result); 75 | return result; 76 | }); 77 | }, 78 | }; 79 | }; 80 | -------------------------------------------------------------------------------- /components/Editor.js: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import { EditorContent, useEditor, ReactNodeViewRenderer } from "@tiptap/react"; 5 | import StarterKit from "@tiptap/starter-kit"; 6 | import Highlight from "@tiptap/extension-highlight"; 7 | import Typography from "@tiptap/extension-typography"; 8 | import Placeholder from "@tiptap/extension-placeholder"; 9 | import Link from "@tiptap/extension-link"; 10 | import js from "highlight.js/lib/languages/javascript"; 11 | import ts from "highlight.js/lib/languages/typescript"; 12 | import { lowlight } from "lowlight"; 13 | 14 | import CodeBlockNode from "./block/CodeBlockNode"; 15 | import SlashCommand from "./editor/SlashCommand"; 16 | import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; 17 | import debounce from "@/lib/hooks/debounce"; 18 | 19 | lowlight.registerLanguage("js", js); 20 | lowlight.registerLanguage("ts", ts); 21 | 22 | const extensions = [ 23 | StarterKit.configure({ 24 | bulletList: { 25 | HTMLAttributes: { 26 | class: "-mt-2", 27 | }, 28 | }, 29 | orderedList: { 30 | HTMLAttributes: { 31 | class: "-mt-2", 32 | }, 33 | }, 34 | listItem: { 35 | HTMLAttributes: { 36 | class: "-mb-2", 37 | }, 38 | }, 39 | code: { 40 | HTMLAttributes: { 41 | spellcheck: "false", 42 | }, 43 | spellcheck: "false", 44 | }, 45 | codeBlock: false, 46 | dropcursor: { 47 | color: "#DBEAFE", 48 | width: 4, 49 | }, 50 | gapcursor: false, 51 | }), 52 | Placeholder.configure({ 53 | placeholder: ({ node }) => { 54 | if (node.type.name === "heading") { 55 | return "Untitled notebook"; 56 | } 57 | if (node.type.name === "paragraph") { 58 | return "Press '/' for commands, or start typing..."; 59 | } 60 | }, 61 | }), 62 | CodeBlockLowlight.extend({ 63 | addNodeView() { 64 | return ReactNodeViewRenderer(CodeBlockNode); 65 | }, 66 | addAttributes() { 67 | return { 68 | language: { 69 | default: "ts", 70 | }, 71 | result: { 72 | default: null, 73 | }, 74 | inputHash: { 75 | default: null, 76 | }, 77 | }; 78 | }, 79 | }).configure({ lowlight }), 80 | Highlight, 81 | Typography, 82 | Link, 83 | SlashCommand, 84 | ]; 85 | 86 | const Editor = ({ content, onUpdate }) => { 87 | const editor = useEditor({ 88 | extensions, 89 | content, 90 | onUpdate: debounce((e) => { 91 | onUpdate(e.editor.getHTML()); 92 | }), 93 | autofocus: !content ? 'start' : false, 94 | }); 95 | 96 | useEffect(() => { 97 | if (editor && content === '

') { 98 | editor.commands.focus(); 99 | } 100 | }, [editor, content]); 101 | 102 | return ( 103 |
104 | 105 |
106 | ); 107 | }; 108 | 109 | export default Editor; 110 | -------------------------------------------------------------------------------- /components/Draft.js: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | import Link from "next/link"; 6 | 7 | import Editor from "@/components/Editor"; 8 | import { saveNotebook } from "@/lib/api/notebooks"; 9 | import { loadEsbuild } from "@/lib/bundler"; 10 | 11 | const Draft = ({ notebook }) => { 12 | const router = useRouter(); 13 | const [loading, setLoading] = useState(false); 14 | const [content, setContent] = useState(notebook.content); 15 | const [title, setTitle] = useState(notebook.title || 'Untitled notebook'); 16 | 17 | const save = async () => { 18 | setLoading(true); 19 | 20 | try { 21 | await saveNotebook({ 22 | ...notebook, 23 | title, 24 | content, 25 | }); 26 | } catch (e) { 27 | alert( 28 | "Sorry, we failed to save the changes. Check your internet connection and try again." 29 | ); 30 | } 31 | 32 | setLoading(false); 33 | }; 34 | 35 | const createNew = async () => { 36 | setLoading(true); 37 | 38 | // create empty notebook 39 | const id = await saveNotebook({ 40 | content: "

" 41 | }); 42 | 43 | router.push(`/${id}`); 44 | }; 45 | 46 | const keyboardShortcutSave = (e) => { 47 | if (e.key === "s" && e.metaKey) { 48 | e.preventDefault(); 49 | 50 | save(); 51 | } 52 | }; 53 | 54 | useEffect(() => { 55 | if (!notebook.id) { 56 | return; 57 | } 58 | window.addEventListener("keydown", keyboardShortcutSave); 59 | return () => window.removeEventListener("keydown", keyboardShortcutSave); 60 | }, []); 61 | 62 | useEffect(() => { 63 | try { 64 | loadEsbuild(); 65 | } catch (_) { 66 | // ignore 67 | } 68 | }, []); 69 | 70 | return ( 71 |
72 | {/* --- Menu bar --- */} 73 |
74 | 📘 75 |
76 | {!loading && } 77 | {loading && ( 78 | Loading... 79 | )} 80 | {!loading && notebook.id && } 81 | {!loading && !notebook.id && ( 82 | 83 | View on GitHub 84 | 85 | )} 86 |
87 |
88 | 89 | {/* --- Editor --- */} 90 |
91 |
92 |
93 | setTitle(e.target.value)} 98 | /> 99 |
100 | 101 |
102 |
103 |
104 | ); 105 | }; 106 | 107 | export default Draft; 108 | -------------------------------------------------------------------------------- /components/IncludableLabel.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const IncludableLabel = ({}) => { 4 | return ( 5 | 11 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default IncludableLabel; 29 | -------------------------------------------------------------------------------- /components/block/Preview.js: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef, useEffect, useState, useId } from "react"; 2 | import * as crc32 from "crc-32"; 3 | 4 | import esBundle from "@/lib/bundler"; 5 | import LogLine from "./LogLine"; 6 | import debounce from "@/lib/hooks/debounce"; 7 | 8 | const html = ` 9 | 10 | 11 | 12 |
13 | 16 | 17 | 89 | 90 | 91 | 92 | `; 93 | 94 | const Preview = ({ input, attributes, updateAttributes }) => { 95 | const iframe = useRef(); 96 | const id = useId(); 97 | const [loading, setLoading] = useState(false); 98 | const [inputHash, setInputHash] = useState(); 99 | const [result, setResult] = useState( 100 | () => (attributes.result && JSON.parse(attributes.result)) || [] 101 | ); 102 | 103 | useEffect(() => { 104 | const newHash = crc32.str(input); 105 | if (newHash === attributes.inputHash) { 106 | console.log("skipping recompile"); 107 | return; 108 | } 109 | 110 | setInputHash(newHash); 111 | compile(); 112 | }, [input]); 113 | 114 | useEffect(() => { 115 | if (!inputHash || !result || !result.length) { 116 | return; 117 | } 118 | 119 | updateAttributes({ 120 | result: JSON.stringify(result), 121 | inputHash, 122 | }); 123 | console.log("Result updated"); 124 | }, [result, inputHash]); 125 | 126 | useEffect(() => { 127 | window.addEventListener("message", (event) => { 128 | if (event.data.id === id) { 129 | console.log("Message received", event.data); 130 | setResult(event.data.result || []); 131 | } 132 | }); 133 | }, []); 134 | 135 | const compile = useCallback( 136 | debounce(async () => { 137 | console.log("Compiling", input); 138 | input = input.trim(); 139 | if (!input) return; 140 | 141 | setLoading(true); 142 | const { output, error } = await esBundle(input, true); 143 | if (error) { 144 | setResult([["ERR", error.toString()]]); 145 | } else { 146 | const code = `window.codeRunner = async function () { \n${output}\n }`; 147 | iframe.current?.contentWindow?.postMessage({ id, code }, "*"); 148 | } 149 | setLoading(false); 150 | }), 151 | [input] 152 | ); 153 | 154 | return ( 155 |
156 |