├── .gitignore ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── public └── esbuild.wasm ├── src ├── App.tsx ├── ReactPlaygroundLogo.svg ├── assets │ ├── editor │ │ └── react-theme.css │ └── icons │ │ └── logo.svg ├── components │ ├── editor │ │ ├── Editor.tsx │ │ ├── EditorFallback.tsx │ │ ├── MonacoEditor.tsx │ │ ├── Tab.tsx │ │ ├── TabInput.tsx │ │ └── TabsContainer.tsx │ ├── output │ │ ├── Console.tsx │ │ ├── Iframe.tsx │ │ └── MiniBrowser.tsx │ ├── playground │ │ ├── Navbar.tsx │ │ └── Playground.tsx │ └── ui-elements │ │ ├── AddButton.tsx │ │ ├── Anchor.tsx │ │ ├── BrushButton.tsx │ │ ├── Button.tsx │ │ ├── DeleteButton.tsx │ │ ├── DesktopVerticalSplitPane.tsx │ │ ├── Loader.tsx │ │ ├── MobileVerticalSplitPane.tsx │ │ ├── Tooltip.tsx │ │ ├── VerticalSplitPane.tsx │ │ └── icons │ │ ├── AddSVG.tsx │ │ ├── BrushSVG.tsx │ │ ├── BugSVG.tsx │ │ ├── CSSLogoSVG.tsx │ │ ├── CloseSVG.tsx │ │ ├── CodeSanboxLogoSVG.tsx │ │ ├── DownloadSVG.tsx │ │ ├── ExpandSVG.tsx │ │ ├── JavaScripLogoSVG.tsx │ │ ├── ReactLogoSVG.tsx │ │ ├── ReactPlaygroundLogoSVG.tsx │ │ ├── RefreshSVG.tsx │ │ ├── ShareSVG.tsx │ │ ├── StackblitzLogoSVG.tsx │ │ └── ThreeVerticalDotsSVG.tsx ├── contexts │ └── URLStateContext.tsx ├── entities │ ├── URLStateEntity.ts │ ├── VFSStateEntity.ts │ └── index.ts ├── favicon.svg ├── hooks │ └── playground │ │ ├── editor │ │ ├── setupMonacoEditor.ts │ │ ├── useCodeFormatter.ts │ │ └── useTabsScroller.ts │ │ ├── useEsbuild.ts │ │ ├── usePreviosValue.ts │ │ ├── useURLStorage.ts │ │ ├── useVFS.ts │ │ └── useWindowSize.ts ├── index.css ├── logo.svg ├── main.tsx ├── mappers │ └── URLStateMapper.ts ├── repositories │ ├── ClipboardRepository.ts │ ├── URLStorageRepository.ts │ ├── impl │ │ ├── ClipboardRepositoryImpl.ts │ │ ├── URLStorageRepositoryImpl.ts │ │ └── index.ts │ └── index.ts ├── tools │ ├── browserDOM-tools.ts │ ├── clipboard-tools.ts │ ├── codemirror-tools.ts │ ├── console-tools.ts │ ├── context-tools.tsx │ ├── editor-tools.ts │ ├── esbuild-tools.ts │ ├── exports-tools.ts │ ├── iframe-tools.ts │ └── style-tools.ts ├── useCases │ ├── URLStateUseCases.ts │ ├── VFSStateUseCases.ts │ └── index.ts ├── vite-env.d.ts └── workers │ └── codeFormatter │ ├── codeFormatter.worker.ts │ └── prettier │ ├── doc.d.ts │ ├── doc.js │ ├── index.d.ts │ ├── index.js │ ├── plugins │ ├── babel.d.ts │ ├── babel.js │ ├── estree.d.ts │ ├── estree.js │ ├── postcss.d.ts │ ├── postcss.js │ ├── typescript.d.ts │ └── typescript.js │ ├── standalone.d.ts │ └── standalone.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Playground 2 | 3 | With React Playground, you create, test, export, and share your ReactJS Components. 4 | 5 | Here you can : 6 | 7 | 1. 🔗 Share your project. You can get at any moment a shareable link without any registration or login. The page's URL is constantly updated at any keystroke, and you can directly copy it, or use the designed button. 8 | 2. ⚛️ Open several tabs (in JS, JSX and CSS file format), and import/export components and hooks from them. 9 | 3. 📦 Import third parties packages, as you would do it in your text editor (e.g. `import styled from 'styled-components'`). React Playground will import them using internally [_esm.sh_]('https://esm.sh). you can select a version by adding `@` + version. (e.g. `import React from 'react@18.0.0'`). 10 | 4. 🔲 Export your code to Stackblitz and CodeSandbox with a click. 11 | 5. 🖌️ Format your code with prettier. 12 | 13 | ## Known bugs and limitations 14 | 15 | - The bundler will automatically import from _esm.sh_, and is therefore subject to any limitations coming from the CDN (e.g. some dependencies cannot be imported) 16 | - You can only open `.jsx`, `.js`, `.css` tabs. Additionally, you can style your components with CSS in js libraries, such as styled-components 17 | - Only JS/JSX third-party libraries can be imported (no CSS @import). However, most CSS-in-JS libraries, like styled-components work normally. 18 | 19 | ## Technologies used 20 | 21 | - [ReactJS](https://reactjs.org/) 22 | - [styled-components](https://styled-components.com/) 23 | - [evento-react](https://www.npmjs.com/package/evento-react/v/0.2.1), for Component events and less prop-drilling 24 | - [lz-string](https://pieroxy.net/blog/pages/lz-string/index.html), to compress the project code for the shareable URL 25 | - [Vite](https://vitejs.dev/), for scaffolding 26 | - [CodeMirror](https://codemirror.net/), for the online editor (in particular [@uiwjs/react-codemirror](https://github.com/uiwjs/react-codemirror)) 27 | - [_esbuild wasm_](https://www.npmjs.com/package/esbuild-wasm), to quickly transpile and bundle on the browser 28 | - [prettier](https://prettier.io/), for the online code formatting. 29 | 30 | ## Credits 31 | 32 | React Playground is inspired by [SvelteJS REPL](https://svelte.dev/repl/hello-world), [SolidJS Playground](https://playground.solidjs.com/), and [VueJS Playground](https://sfc.vuejs.org/). 33 | 34 | [This Udemy course](https://www.udemy.com/course/react-and-typescript-build-a-portfolio-project/) by Stephen Grider was essential to learn how to work with bundlers in the browser. 35 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React Playground 8 | 12 | 16 | 20 | 23 | 29 | 30 | 31 |
32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactplayground", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "tsc && vite build", 8 | "preview": "vite preview" 9 | }, 10 | "dependencies": { 11 | "@codemirror/autocomplete": "^6.11.1", 12 | "@codemirror/lang-css": "^6.2.1", 13 | "@codemirror/lang-javascript": "^6.2.1", 14 | "@codemirror/view": "^6.23.0", 15 | "@lezer/highlight": "^1.2.0", 16 | "@monaco-editor/react": "^4.6.0", 17 | "@stackblitz/sdk": "^1.9.0", 18 | "@typescript/ata": "^0.9.4", 19 | "@typescript/vfs": "^1.5.0", 20 | "@uiw/codemirror-themes": "^4.21.21", 21 | "@uiw/react-codemirror": "^4.21.21", 22 | "axios": "^0.26.1", 23 | "code-mirror-themes": "^1.0.0", 24 | "codemirror": "^5.65.16", 25 | "console-feed": "^3.5.0", 26 | "dedent": "^0.7.0", 27 | "esbuild-wasm": "^0.14.54", 28 | "evento-react": "^0.2.2", 29 | "jszip": "^3.10.1", 30 | "localforage": "^1.10.0", 31 | "lz-string": "^1.5.0", 32 | "react": "^18.2.0", 33 | "react-codemirror2": "^7.3.0", 34 | "react-dom": "^18.2.0", 35 | "react-is": "^18.2.0", 36 | "styled-components": "^5.3.11" 37 | }, 38 | "devDependencies": { 39 | "@rollup/plugin-alias": "^3.1.9", 40 | "@types/codemirror": "^5.60.15", 41 | "@types/dedent": "^0.7.2", 42 | "@types/linkifyjs": "^2.1.7", 43 | "@types/lz-string": "^1.5.0", 44 | "@types/node": "^18.19.4", 45 | "@types/react": "^18.2.46", 46 | "@types/react-dom": "^18.2.18", 47 | "@types/styled-components": "^5.1.34", 48 | "@vitejs/plugin-react": "^2.2.0", 49 | "typescript": "^5.3.3", 50 | "vite": "^3.2.7" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /public/esbuild.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AyloSrd/reactplayground/0e28cbfe237ba91065f7e23fef1a1f4dc90fd77d/public/esbuild.wasm -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import Loader from "@/components/ui-elements/Loader"; 2 | import Playground from "@/components/playground/Playground"; 3 | import { VFS } from "@/hooks/playground/useVFS"; 4 | import { useCallback } from "react"; 5 | import { useURLState, vfsFromURLSelector } from "@/contexts/URLStateContext"; 6 | import { runWhenBrowserIsIdle } from "@/tools/browserDOM-tools"; 7 | 8 | function App() { 9 | const [initialVFS, { updateURLState }] = useURLState({ 10 | lazy: true, 11 | selector: vfsFromURLSelector, 12 | }); 13 | 14 | const handleUpdateVFS = useCallback( 15 | (e: CustomEvent) => { 16 | runWhenBrowserIsIdle(() => updateURLState({ ts: false, vfs: e.detail })); 17 | }, 18 | [updateURLState] 19 | ); 20 | 21 | return ( 22 |
23 | {initialVFS === undefined ? ( 24 | 25 | ) : ( 26 | 27 | )} 28 |
29 | ); 30 | } 31 | 32 | export default App; 33 | -------------------------------------------------------------------------------- /src/ReactPlaygroundLogo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/editor/react-theme.css: -------------------------------------------------------------------------------- 1 | .cm-s-rdark { 2 | font-size: 1em; 3 | line-height: 1.5em; 4 | font-family: inconsolata, monospace; 5 | letter-spacing: 0.3px; 6 | word-spacing: 1px; 7 | background: #1b2426; 8 | color: #b9bdb6; 9 | } 10 | .cm-s-rdark .CodeMirror-lines { 11 | padding: 8px 0; 12 | } 13 | .cm-s-rdark .CodeMirror-gutters { 14 | box-shadow: 1px 0 2px 0 rgba(0, 0, 0, 0.5); 15 | -webkit-box-shadow: 1px 0 2px 0 rgba(0, 0, 0, 0.5); 16 | background-color: #1b2426; 17 | padding-right: 10px; 18 | z-index: 3; 19 | border: none; 20 | } 21 | .cm-s-rdark div.CodeMirror-cursor { 22 | border-left: 3px solid #b9bdb6; 23 | } 24 | .cm-s-rdark .CodeMirror-activeline-background { 25 | background: #00000070; 26 | } 27 | .cm-s-rdark .CodeMirror-selected { 28 | background: #e0e8ff66; 29 | } 30 | .cm-s-rdark .cm-comment { 31 | color: #646763; 32 | } 33 | .cm-s-rdark .cm-string { 34 | color: #dabc63; 35 | } 36 | .cm-s-rdark .cm-number { 37 | color: null; 38 | } 39 | .cm-s-rdark .cm-atom { 40 | color: null; 41 | } 42 | .cm-s-rdark .cm-keyword { 43 | color: #5ba1cf; 44 | } 45 | .cm-s-rdark .cm-variable { 46 | color: #ffaa3e; 47 | } 48 | .cm-s-rdark .cm-def { 49 | color: #ffffff; 50 | } 51 | .cm-s-rdark .cm-variable-2 { 52 | color: #ffffff; 53 | } 54 | .cm-s-rdark .cm-property { 55 | color: null; 56 | } 57 | .cm-s-rdark .cm-operator { 58 | color: #5ba1cf; 59 | } 60 | 61 | .cm-s-rdark .cm-tag:not(.cm-bracket) { 62 | color: #5ba1cf; 63 | } 64 | 65 | .cm-s-rdark .CodeMirror-linenumber { 66 | color: #646763; 67 | } 68 | -------------------------------------------------------------------------------- /src/assets/icons/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/components/editor/Editor.tsx: -------------------------------------------------------------------------------- 1 | import TabsContainer from "@/components/editor/TabsContainer"; 2 | import usePreviousValue from "@/hooks/playground/usePreviosValue"; 3 | import { ENTRY_POINT_JSX, VFS } from "@/hooks/playground/useVFS"; 4 | import CodeMirror from "@uiw/react-codemirror"; 5 | import { javascript } from "@codemirror/lang-javascript"; 6 | import { css } from "@codemirror/lang-css"; 7 | import { useCreateEvento } from "evento-react"; 8 | import { vsCodish } from "@/tools/codemirror-tools"; 9 | import { memo, useState, useMemo, useCallback, useEffect } from "react"; 10 | import { colors, fixedSizes } from "@/tools/style-tools"; 11 | import styled from "styled-components"; 12 | import { EditorView } from "@codemirror/view"; 13 | import "@codemirror/autocomplete"; 14 | import { useCodeFormatter } from "@/hooks/playground/editor/useCodeFormatter"; 15 | import { 16 | acceptedFileTypes, 17 | type AcceptedFileType, 18 | } from "@/tools/esbuild-tools"; 19 | // import MonacoEditor from "./MonacoEditor"; 20 | 21 | interface Props { 22 | files: { 23 | fileList: string[]; 24 | filesById: VFS; 25 | }; 26 | onAddFile: (e: CustomEvent) => void; 27 | onDeleteFile: (e: CustomEvent) => void; 28 | onEditFileName: (e: CustomEvent<{ current: string; next: string }>) => void; 29 | onTextEditorChange: (e: CustomEvent<{ file: string; text: string }>) => void; 30 | } 31 | 32 | function Editor(props: Props) { 33 | const { 34 | files: { fileList: tabs, filesById }, 35 | } = props; 36 | const [currentFile, setCurrentFile] = useState( 37 | filesById["App.jsx"] ? "App.jsx" : ENTRY_POINT_JSX, 38 | ); 39 | 40 | const prevTabsLength = usePreviousValue(tabs.length); 41 | 42 | const evento = useCreateEvento(props); 43 | 44 | const fileFormat = currentFile.split(".").at(-1); 45 | 46 | const extensions = [ 47 | ...(fileFormat === "css" ? [css()] : [javascript({ jsx: true })]), 48 | EditorView.lineWrapping, 49 | ]; 50 | 51 | const handleTextChange = useCallback( 52 | (text: string) => { 53 | evento("textEditorChange", { file: currentFile, text }); 54 | }, 55 | [currentFile], 56 | ); 57 | 58 | const handleTabCreate = useCallback((e: CustomEvent) => { 59 | evento("addFile", e.detail); 60 | }, []); 61 | 62 | const handleTabDelete = useCallback((e: CustomEvent) => { 63 | evento("deleteFile", e.detail); 64 | }, []); 65 | 66 | const hadleTabEdit = useCallback( 67 | (e: CustomEvent<{ current: string; next: string }>) => { 68 | evento("editFileName", e.detail); 69 | }, 70 | [], 71 | ); 72 | 73 | const handleTabSelect = useCallback((e: CustomEvent) => { 74 | setCurrentFile(e.detail); 75 | }, []); 76 | 77 | const format = useCodeFormatter(); 78 | 79 | const handleFormat = useCallback(() => { 80 | if (typeof fileFormat !== "string") return; 81 | if (!acceptedFileTypes.includes(fileFormat as AcceptedFileType)) return; 82 | 83 | format({ 84 | code: filesById[currentFile], 85 | lang: fileFormat as AcceptedFileType, 86 | onComplete: handleTextChange, 87 | onError: (err) => { 88 | console.warn(`Prettier error: ${err.message}`); 89 | }, 90 | }); 91 | }, [currentFile, fileFormat, filesById, handleTextChange]); 92 | 93 | useEffect(() => { 94 | const tabsLength = tabs.length; 95 | 96 | if (!tabs.includes(currentFile)) { 97 | setCurrentFile(ENTRY_POINT_JSX); 98 | } 99 | 100 | if (tabsLength > prevTabsLength) { 101 | setCurrentFile(tabs[tabsLength - 1]); 102 | } 103 | }, [currentFile, prevTabsLength, tabs]); 104 | 105 | return ( 106 | 107 | 116 | 117 | 118 | {/* */} 119 | 126 | 127 | 128 | 129 | ); 130 | } 131 | 132 | const Container = styled.section` 133 | height: 100%; 134 | max-height: 100%; 135 | flex-grow: 1; 136 | display: grid; 137 | grid-template-rows: ${fixedSizes.editorTabsContainerHeight} 1fr; 138 | `; 139 | 140 | const Scroller = styled.div` 141 | position: relative; 142 | height: 100%; 143 | max-height: 100%; 144 | overflow-y: auto; 145 | background-color: ${colors.$bg}; 146 | `; 147 | 148 | const CodeMirroContainer = styled.div` 149 | height: 100%; 150 | 151 | .cm-theme { 152 | font-family: "Ubuntu Mono", "Courier New", monospace !important; 153 | } 154 | 155 | .cm-scroller > div.cm-content.cm-lineWrapping { 156 | font-family: "Ubuntu Mono", "Courier New", monospace !important; 157 | font-size: 15px; 158 | } 159 | 160 | div.cm-scroller > div.cm-gutters { 161 | font-family: "Ubuntu Mono", "Courier New", monospace !important; 162 | font-size: 15px; 163 | } 164 | `; 165 | 166 | export default memo(Editor); 167 | -------------------------------------------------------------------------------- /src/components/editor/EditorFallback.tsx: -------------------------------------------------------------------------------- 1 | import Loader from "@/components/ui-elements/Loader"; 2 | import styled from "styled-components"; 3 | 4 | export default function EditorFallback() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | 12 | const Container = styled.section` 13 | height: 100%; 14 | max-height: 100%; 15 | flex-grow: 1; 16 | display: grid; 17 | place-content: center; 18 | `; 19 | -------------------------------------------------------------------------------- /src/components/editor/MonacoEditor.tsx: -------------------------------------------------------------------------------- 1 | import { Editor } from "@monaco-editor/react"; 2 | import { setupEditor } from "@/hooks/playground/editor/setupMonacoEditor"; 3 | 4 | const defaultContent = ` 5 | import { useStatus as useState } from './test' 6 | import { ref } from 'vue' 7 | import _ from 'lodash' 8 | 9 | // vue 10 | export const count = ref(10); 11 | 12 | // react 13 | export function useCounter() { 14 | const [count, setCount] = useState(0) 15 | return { count, increment: () => setCount(count + 1) } 16 | } 17 | 18 | // lodash 19 | const res = _.partition([1, 2, 3, 4], n => n % 2); 20 | 21 | // 1. hover the cursor on those variables above to see the types. 22 | // 2. try to import any other library, the types will be automatically loaded. 23 | `; 24 | 25 | import { memo } from "react"; 26 | const TheEditor = memo(() => { 27 | return ( 28 | console.log(e)} 35 | onMount={setupEditor} 36 | options={{ 37 | lineNumbers: "on", 38 | minimap: { enabled: false }, 39 | scrollbar: { horizontal: "auto", vertical: "auto" }, 40 | overviewRulerLanes: 0, 41 | wordWrap: "on", 42 | theme: "vs-dark", 43 | // renderLineHighlight: 'none', 44 | renderLineHighlightOnlyWhenFocus: true, 45 | }} 46 | /> 47 | ); 48 | }); 49 | 50 | export default TheEditor; 51 | -------------------------------------------------------------------------------- /src/components/editor/Tab.tsx: -------------------------------------------------------------------------------- 1 | import { ENTRY_POINT_JSX } from "@/hooks/playground/useVFS"; 2 | import JavaScripLogoSVG from "@/components/ui-elements/icons/JavaScripLogoSVG"; 3 | import ReactLogoSVG from "@/components/ui-elements/icons/ReactLogoSVG"; 4 | import CSSLogoSVG from "@/components/ui-elements/icons/CSSLogoSVG"; 5 | import DeleteButton from "@/components/ui-elements/DeleteButton"; 6 | import { useCreateEvento } from "evento-react"; 7 | import { memo, useCallback } from "react"; 8 | import styled from "styled-components"; 9 | import { colors, languageToColor, makeClassName } from "@/tools/style-tools"; 10 | 11 | interface Props { 12 | currentTab: string; 13 | onDelete: (e: CustomEvent) => void; 14 | onEditRequest: (e: CustomEvent) => void; 15 | onSelect: (e: CustomEvent) => void; 16 | tab: string; 17 | } 18 | 19 | function Tab(props: Props) { 20 | const { currentTab, tab } = props; 21 | 22 | const fileFormat = tab.split(".")[1]; 23 | const isEntryPoint = tab === ENTRY_POINT_JSX; 24 | const tabClassNames = [ 25 | ...(isEntryPoint ? ["is-entry-point"] : []), 26 | ...(tab === currentTab ? ["is-selected"] : []), 27 | ]; 28 | 29 | const evento = useCreateEvento(props); 30 | 31 | const handleTabClick = useCallback(() => { 32 | evento("select", tab); 33 | }, [tab]); 34 | 35 | const handleDoubleClick = useCallback(() => { 36 | if (isEntryPoint) { 37 | return; 38 | } 39 | evento("editRequest", tab); 40 | }, []); 41 | 42 | const handleDeleteCkick = useCallback(() => { 43 | if (isEntryPoint) { 44 | return; 45 | } 46 | evento("delete", tab); 47 | }, [tab]); 48 | 49 | return ( 50 | 54 | 55 | 56 | {fileFormat === "js" ? ( 57 | 58 | ) : fileFormat === "jsx" ? ( 59 | 60 | ) : fileFormat === "css" ? ( 61 | 62 | ) : null} 63 | 64 | {tab} 65 | 66 | {!isEntryPoint && } 67 | 68 | ); 69 | } 70 | 71 | export const TabContainer = styled.li<{ underliningColor: string }>` 72 | display: flex; 73 | align-items: center; 74 | flex: 0 0 auto; 75 | height: 100%; 76 | position: relative; 77 | padding: 5px 30px 5px 0; 78 | margin: 0 0 0 10px; 79 | color: ${colors.$silver200}; 80 | 81 | &.is-entry-point { 82 | padding-right: 0; 83 | margin-right: 5px; 84 | } 85 | 86 | &.is-selected { 87 | box-shadow: inset 0px -2px 0px 0px ${(props) => props.underliningColor}; 88 | color: ${colors.$silver100}; 89 | } 90 | `; 91 | 92 | const StyledJavaScriptLogoSVG = styled(JavaScripLogoSVG)` 93 | margin-right: 5px; 94 | `; 95 | 96 | const StyledCSSLogoSVG = styled(CSSLogoSVG)` 97 | margin-right: 5px; 98 | `; 99 | 100 | const Pointer = styled.div` 101 | cursor: pointer; 102 | display: flex; 103 | align-items: center; 104 | `; 105 | 106 | export default memo(Tab); 107 | -------------------------------------------------------------------------------- /src/components/editor/TabInput.tsx: -------------------------------------------------------------------------------- 1 | import { ENTRY_POINT_JSX } from "@/hooks/playground/useVFS"; 2 | import { useCreateEvento } from "evento-react"; 3 | import React, { memo, useCallback, useEffect, useRef, useState } from "react"; 4 | import styled from "styled-components"; 5 | import { colors } from "@/tools/style-tools"; 6 | import { validateTabName } from "@/tools/editor-tools"; 7 | 8 | interface Props { 9 | existingTabNames: string[]; 10 | onNewNameSubmit: (e: CustomEvent<{ current: string; next: string }>) => void; 11 | tab: string; 12 | } 13 | 14 | function TabInput(props: Props) { 15 | const { existingTabNames, tab } = props; 16 | 17 | const inputRef = useRef(null); 18 | const errorsRef = useRef>([]); 19 | const evento = useCreateEvento(props); 20 | 21 | const handleChange = useCallback( 22 | (e: React.ChangeEvent) => { 23 | errorsRef.current = validateTabName( 24 | e.target.value, 25 | tab, 26 | existingTabNames, 27 | ); 28 | }, 29 | [tab, existingTabNames], 30 | ); 31 | 32 | const handleSubmit = useCallback( 33 | (e?: React.FormEvent): void => { 34 | e?.preventDefault(); 35 | 36 | if (errorsRef.current.length > 0) { 37 | alert(errorsRef.current[0]); 38 | return; 39 | } 40 | 41 | const nextTabName = inputRef.current?.value; 42 | 43 | if (typeof nextTabName !== "string") return; 44 | 45 | evento("newNameSubmit", { current: tab, next: nextTabName }); 46 | }, 47 | [tab], 48 | ); 49 | 50 | const handleBlur = useCallback(() => { 51 | if (errorsRef.current.length > 0) { 52 | inputRef.current?.select(); 53 | return; 54 | } 55 | handleSubmit(); 56 | }, [handleSubmit]); 57 | 58 | useEffect(() => { 59 | inputRef.current?.select(); 60 | }, []); 61 | 62 | return ( 63 | 64 | 65 |
66 | 72 |
73 |
74 |
75 | ); 76 | } 77 | 78 | const Container = styled.li` 79 | height: 100%; 80 | padding: 5px; 81 | display: block; 82 | flex: 0 0 auto; 83 | position: relative; 84 | box-shadow: inset 0px -2px 0px 0px ${colors.$react}; 85 | `; 86 | const Flex = styled.div` 87 | height: 100%; 88 | display: flex; 89 | align-items: center; 90 | `; 91 | 92 | const Input = styled.input` 93 | padding: 0; 94 | height: 30px; 95 | width: 100px; 96 | border: none; 97 | box-shadow: inset 0px -1px 0px 0px ${colors.$silver100}; 98 | color: ${colors.$silver100}; 99 | background-color: ${colors.$bg}; 100 | `; 101 | 102 | export default memo(TabInput); 103 | -------------------------------------------------------------------------------- /src/components/editor/TabsContainer.tsx: -------------------------------------------------------------------------------- 1 | import AddButton from "@/components/ui-elements/AddButton"; 2 | import BrushButton from "@/components/ui-elements/BrushButton"; 3 | import Tab from "@/components/editor/Tab"; 4 | import TabInput from "@/components/editor/TabInput"; 5 | import { ENTRY_POINT_JSX } from "@/hooks/playground/useVFS"; 6 | import { generateNewTabName } from "@/tools/editor-tools"; 7 | import { useCreateEvento } from "evento-react"; 8 | import { memo, useCallback, useState } from "react"; 9 | import { colors, generalBorderStyle } from "@/tools/style-tools"; 10 | import styled from "styled-components"; 11 | import Tooltip from "@/components/ui-elements/Tooltip"; 12 | import { useTabsScroller } from "@/hooks/playground/editor/useTabsScroller"; 13 | interface Props { 14 | currentTab: string; 15 | onFormat: () => void; 16 | onTabCreate: (e: CustomEvent) => void; 17 | onTabDelete: (e: CustomEvent) => void; 18 | onTabEdit: (e: CustomEvent<{ current: string; next: string }>) => void; 19 | onTabSelect: (e: CustomEvent) => void; 20 | tabs: string[]; 21 | } 22 | 23 | function TabsContainer(props: Props) { 24 | const { currentTab, tabs } = props; 25 | const { 26 | containerRef, 27 | tabsRef, 28 | isOverflowedLeft, 29 | isOverflowedRight, 30 | handleScroll, 31 | } = useTabsScroller({ tabs }); 32 | 33 | const [editedTab, setEditedTab] = useState(null); 34 | const [newTab, setNewTab] = useState(null); 35 | 36 | const evento = useCreateEvento(props); 37 | 38 | const handleAddClick = useCallback(() => { 39 | setNewTab(generateNewTabName(tabs)); 40 | }, []); 41 | 42 | const handleNewTabAdd = useCallback( 43 | (e: CustomEvent<{ current: string; next: string }>) => { 44 | evento("tabCreate", e.detail.next).then((res) => { 45 | if (res) { 46 | setNewTab(null); 47 | } 48 | }); 49 | }, 50 | [newTab], 51 | ); 52 | 53 | const handleTabDelete = useCallback( 54 | (e: CustomEvent) => { 55 | evento("tabDelete", e.detail); 56 | }, 57 | [evento], 58 | ); 59 | 60 | const handleTabEdit = useCallback( 61 | (e: CustomEvent<{ current: string; next: string }>) => { 62 | const { current, next } = e.detail; 63 | if (next === ENTRY_POINT_JSX) { 64 | setEditedTab(null); 65 | return; 66 | } 67 | 68 | evento("tabEdit", { current, next }).then((res) => { 69 | if (res) { 70 | setEditedTab(null); 71 | } 72 | }); 73 | }, 74 | [], 75 | ); 76 | 77 | const handleTabEditRequest = useCallback((e: CustomEvent) => { 78 | setEditedTab(e.detail); 79 | }, []); 80 | 81 | const handleTabSelect = useCallback( 82 | (e: CustomEvent) => { 83 | evento("tabSelect", e.detail); 84 | }, 85 | [evento], 86 | ); 87 | 88 | const handleFormatClick = useCallback(() => { 89 | evento("format"); 90 | }, [evento]); 91 | 92 | return ( 93 | 94 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | ); 134 | } 135 | 136 | const Nav = styled.nav` 137 | position: relative; 138 | width: 100%; 139 | max-width: 100%; 140 | flex-wrap: nowrap; 141 | overflow-x: auto; 142 | -ms-overflow-style: none; 143 | scrollbar-width: none; 144 | 145 | &::-webkit-scrollbar { 146 | display: none; 147 | } 148 | `; 149 | 150 | const Tabs = styled.ul<{ 151 | hasRightPadding: boolean; 152 | }>` 153 | width: 100%; 154 | list-style-type: none; 155 | height: 100%; 156 | margin: 0; 157 | padding: ${(hasRightPadding) => (hasRightPadding ? "0 50px 0 0" : "0")}; 158 | display: flex; 159 | align-items: center; 160 | `; 161 | 162 | const Container = styled.div<{ 163 | hasBefore: boolean; 164 | }>` 165 | position: relative; 166 | width: 100%; 167 | display: grid; 168 | grid-template-columns: 1fr auto; 169 | border-bottom: ${generalBorderStyle}; 170 | &::before { 171 | content: ""; 172 | position: absolute; 173 | top: 0; 174 | bottom: 0; 175 | width: 50px; 176 | pointer-events: none; 177 | left: 0; 178 | background-image: linear-gradient(to right, ${colors.$bg}, transparent); 179 | z-index: 2; 180 | opacity: ${({ hasBefore }) => (hasBefore ? 1 : 0)}; 181 | } 182 | `; 183 | 184 | const Buttons = styled.div<{ 185 | hasBefore: boolean; 186 | }>` 187 | padding: 0 10px; 188 | display: flex; 189 | align-items: center; 190 | justify-content: flex-end; 191 | gap: 5px; 192 | position: relative; 193 | &::before { 194 | content: ""; 195 | position: absolute; 196 | top: 0; 197 | bottom: 0; 198 | width: 50px; 199 | pointer-events: none; 200 | left: -50px; 201 | background-image: linear-gradient(to left, ${colors.$bg}, transparent); 202 | z-index: 2; 203 | opacity: ${({ hasBefore }) => (hasBefore ? 1 : 0)}; 204 | } 205 | `; 206 | 207 | export default memo(TabsContainer); 208 | -------------------------------------------------------------------------------- /src/components/output/Console.tsx: -------------------------------------------------------------------------------- 1 | import Button from "@/components/ui-elements/Button"; 2 | import ExpandSVG from "@/components/ui-elements/icons/ExpandSVG"; 3 | import { 4 | colors, 5 | fixedSizes, 6 | generalBorderStyle, 7 | transitionDuration, 8 | } from "@/tools/style-tools"; 9 | import { consoleStyles } from "@/tools/console-tools"; 10 | import { Console as Logs } from "console-feed"; 11 | import { Message } from "console-feed/lib/definitions/Component"; 12 | import { useCreateEvento } from "evento-react"; 13 | import { memo, useCallback, useEffect, useRef, useState } from "react"; 14 | import styled from "styled-components"; 15 | 16 | interface Props { 17 | logs: Message[]; 18 | onClear: () => any; 19 | } 20 | 21 | const Console = (props: Props) => { 22 | const { logs } = props; 23 | 24 | const [isConsoleOpen, setIsConsoleOpen] = useState(false); 25 | 26 | const scrollRef = useRef(null); 27 | 28 | const evento = useCreateEvento(props); 29 | 30 | const handleClearClick = useCallback(() => { 31 | evento("clear"); 32 | }, []); 33 | 34 | const handleOpenCloseConsole = useCallback( 35 | (e: React.ChangeEvent) => { 36 | setIsConsoleOpen(e.target.checked); 37 | }, 38 | [], 39 | ); 40 | 41 | useEffect(() => { 42 | if (isConsoleOpen) { 43 | scrollRef?.current?.scrollIntoView({ 44 | behavior: "smooth", 45 | }); 46 | } 47 | }, [logs]); 48 | 49 | return ( 50 |
51 | 70 | 71 | 72 |
73 | 74 |
75 | ); 76 | }; 77 | 78 | const Section = styled.section` 79 | background-color: ${colors.$bg}; 80 | justify-self: stretch; 81 | display: flex; 82 | flex-direction: column; 83 | min-width: 0; 84 | `; 85 | 86 | const ConsoleBody = styled.div` 87 | overflow: auto; 88 | min-width: 0; 89 | 90 | &.open { 91 | height: 200px; 92 | max-height: 200px; 93 | transition: ${transitionDuration.fast}; 94 | border-top: ${generalBorderStyle}; 95 | } 96 | 97 | &.closed { 98 | height: 200px; 99 | max-height: 0; 100 | transition: ${transitionDuration.fast}; 101 | } 102 | `; 103 | 104 | const BtnContent = styled.span` 105 | color: ${colors.$silver200}; 106 | 107 | &:hover { 108 | color: ${colors.$silver100}; 109 | text-decoration: underline; 110 | } 111 | `; 112 | 113 | const Nav = styled.nav` 114 | height: ${fixedSizes.editorTabsContainerHeight}; 115 | display: flex; 116 | justify-content: space-between; 117 | padding: 0 10px; 118 | `; 119 | 120 | const Label = styled.label` 121 | cursor: pointer; 122 | display: flex; 123 | align-items: center; 124 | 125 | &:hover { 126 | color: ${colors.$silver100}; 127 | } 128 | `; 129 | 130 | const OpenCloseCheckbox = styled.input` 131 | display: none; 132 | `; 133 | 134 | export default memo(Console); 135 | -------------------------------------------------------------------------------- /src/components/output/Iframe.tsx: -------------------------------------------------------------------------------- 1 | import { OutputType } from "@/hooks/playground/useEsbuild"; 2 | import { sandboxAttributes, srcDoc } from "@/tools/iframe-tools"; 3 | import { useCreateEvento } from "evento-react"; 4 | import { memo, useCallback, useEffect, useRef } from "react"; 5 | import styled from "styled-components"; 6 | 7 | interface Props { 8 | onLoad: (evt: CustomEvent) => void; 9 | onPageRefresh: () => void; 10 | output: OutputType; 11 | shouldRefresh: boolean; 12 | } 13 | 14 | const Iframe = (props: Props) => { 15 | const { output, shouldRefresh } = props; 16 | 17 | const iframeRef = useRef(null); 18 | 19 | const evento = useCreateEvento(props); 20 | 21 | const handleIframeLoad = useCallback(() => { 22 | const iframeWindow = iframeRef?.current?.contentWindow; 23 | 24 | if (iframeWindow) { 25 | iframeWindow.postMessage(output, "*"); 26 | evento("load", iframeWindow); 27 | } 28 | }, [output, props]); 29 | 30 | useEffect(() => { 31 | if (shouldRefresh && iframeRef && iframeRef.current) { 32 | iframeRef.current.srcdoc = srcDoc; 33 | evento("pageRefresh"); 34 | } 35 | iframeRef?.current?.contentWindow?.postMessage(output, "*"); 36 | }, [output, shouldRefresh]); 37 | 38 | return ( 39 | 47 | ); 48 | }; 49 | 50 | const StyledIframe = styled.iframe` 51 | border: none; 52 | height: 100%; 53 | width: 100%; 54 | `; 55 | 56 | export default memo(Iframe); 57 | -------------------------------------------------------------------------------- /src/components/output/MiniBrowser.tsx: -------------------------------------------------------------------------------- 1 | import Button from "@/components/ui-elements/Button"; 2 | import RefreshSVG from "@/components/ui-elements/icons/RefreshSVG"; 3 | import Iframe from "@/components/output/Iframe"; 4 | import { OutputType } from "@/hooks/playground/useEsbuild"; 5 | import { 6 | colors, 7 | fixedSizes, 8 | generalBorderStyle, 9 | transitionDuration, 10 | } from "@/tools/style-tools"; 11 | import Console from "@/components/output/Console"; 12 | import { Hook, Decode } from "console-feed"; 13 | import { Message } from "console-feed/lib/definitions/Component"; 14 | import { memo, useCallback, useEffect, useState } from "react"; 15 | import styled from "styled-components"; 16 | 17 | interface Props { 18 | output: OutputType; 19 | } 20 | 21 | const MiniBrowser = (props: Props) => { 22 | const { output } = props; 23 | 24 | const [logs, setLogs] = useState([]); 25 | const [shouldRefresh, setShouldRefresh] = useState(false); 26 | 27 | const handleConsoleMessage = useCallback( 28 | (log: Message[]) => { 29 | setLogs( 30 | log[0].method === "clear" 31 | ? [] 32 | : (currLogs: Message[]) => [...currLogs, Decode(log)] as Message[], 33 | ); 34 | }, 35 | [Decode], 36 | ); 37 | 38 | const handleLoad = useCallback((evt: CustomEvent) => { 39 | Hook( 40 | // @ts-ignore : Window type soens't have console 41 | evt.detail.console, 42 | // @ts-ignore : cannot make make ts work with this callback 43 | handleConsoleMessage, 44 | true, 45 | 100, 46 | ); 47 | }, []); 48 | 49 | const handleClearConsole = useCallback(() => { 50 | setLogs([]); 51 | }, []); 52 | 53 | const handlePageRefresh = useCallback(() => { 54 | handleClearConsole(); 55 | setShouldRefresh(false); 56 | }, []); 57 | 58 | const handleRequestRefreshClick = useCallback(() => { 59 | setShouldRefresh(true); 60 | }, []); 61 | 62 | useEffect(() => { 63 | handleClearConsole(); 64 | }, [output]); 65 | 66 | return ( 67 | 68 | 75 |