├── .gitignore ├── README.md ├── app ├── (helpers) │ └── proxy │ │ └── route.js ├── [id] │ └── page.js ├── globals.scss ├── layout.js └── page.js ├── components ├── Draft.js ├── Editor.js ├── IncludableLabel.js ├── block │ ├── CodeBlockNode.js │ ├── LogLine.js │ └── Preview.js └── editor │ ├── SlashCommand.tsx │ └── defaultContent.js ├── jsconfig.json ├── lib ├── api │ └── notebooks.js ├── bundler │ ├── index.ts │ └── plugins │ │ ├── fetch-plugin.ts │ │ └── unpkg-path-plugin.ts └── hooks │ ├── debounce.js │ └── useLocalStorage.ts ├── next.config.js ├── package.json ├── postcss.config.js ├── tailwind.config.js └── yarn.lock /.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 | -------------------------------------------------------------------------------- /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/(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/[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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 |