├── .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 |
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 |
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 |
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 |
81 |
82 |
83 | );
84 | };
85 |
86 | const BtnContent = styled.div`
87 | transition: transform ${transitionDuration.fast};
88 |
89 | &:hover {
90 | color: ${colors.$silver100};
91 | }
92 | `;
93 |
94 | const Container = styled.section`
95 | background-color: ${colors.$silver100};
96 | height: 100%;
97 | flex-grow: 1;
98 | display: grid;
99 | grid-template-rows: ${fixedSizes.editorTabsContainerHeight} 1fr auto;
100 | `;
101 |
102 | const Nav = styled.nav`
103 | background-color: ${colors.$bg};
104 | display: flex;
105 | align-items: center;
106 | padding-left: 10px;
107 | border-bottom: ${generalBorderStyle};
108 | `;
109 |
110 | export default memo(MiniBrowser);
111 |
--------------------------------------------------------------------------------
/src/components/playground/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import ReactPlaygroundLogoSVG from "@/components/ui-elements/icons/ReactPlaygroundLogoSVG";
2 | import Anchor from "@/components/ui-elements/Anchor";
3 | import BugSVG from "@/components/ui-elements/icons/BugSVG";
4 | import ShareSVG from "@/components/ui-elements/icons/ShareSVG";
5 | import Button from "@/components/ui-elements/Button";
6 | import CodeSandboxLogoSVG from "@/components/ui-elements/icons/CodeSanboxLogoSVG";
7 | import StackblitzLogoSVG from "@/components/ui-elements/icons/StackblitzLogoSVG";
8 | import DownloadSVG from "@/components/ui-elements/icons/DownloadSVG";
9 | import useURLStorage from "@/hooks/playground/useURLStorage";
10 | import { colors, fixedSizes, generalBorderStyle } from "@/tools/style-tools";
11 | import { memo, useCallback } from "react";
12 | import styled from "styled-components";
13 | import { useCreateEvento } from "evento-react";
14 |
15 | interface Props {
16 | onExportToZip: () => void;
17 | onExportToCodeSandbox: () => void;
18 | onExportToStackblitz: () => void;
19 | onReloadPlayground: () => void;
20 | }
21 |
22 | const Navbar = (props: Props) => {
23 | const evento = useCreateEvento(props);
24 | const { copyURLToClipBoard } = useURLStorage();
25 |
26 | const handleExportToZip = useCallback(() => {
27 | evento("exportToZip");
28 | }, [props]);
29 |
30 | const handleExportToCodeSandboxClick = useCallback(() => {
31 | evento("exportToCodeSandbox");
32 | }, [props]);
33 |
34 | const handleExportToStackblitz = useCallback(() => {
35 | evento("exportToStackblitz");
36 | }, [props]);
37 |
38 | const handleReloadClick = useCallback(() => {
39 | evento("reloadPlayground");
40 | }, []);
41 |
42 | const handleShareClick = useCallback(() => {
43 | copyURLToClipBoard().then(() => alert("link copied to clipboard"));
44 | }, []);
45 |
46 | return (
47 |
48 |
96 |
97 | );
98 | };
99 |
100 | const Nav = styled.nav`
101 | height: ${fixedSizes.navbarHeight};
102 | width: 100vw;
103 | max-width: 100%;
104 | display: flex;
105 | flex-direction: row;
106 | align-items: center;
107 | justify-content: space-between;
108 | background-color: ${colors.$bg};
109 | padding: 0 10px;
110 | border-bottom: ${generalBorderStyle};
111 | `;
112 |
113 | const ButtonContainer = styled.div`
114 | display: flex;
115 | gap: 10px;
116 | margin-right: 20px;
117 | `;
118 |
119 | const BtnContent = styled.div`
120 | &:hover {
121 | color: ${colors.$silver100};
122 | }
123 | `;
124 |
125 | const TitleContainer = styled.div`
126 | display: grid;
127 | grid-template-columns: auto auto;
128 | align-items: center;
129 | gap: 10px;
130 | cursor: pointer;
131 | `;
132 |
133 | const Title = styled.h1`
134 | display: inline-block;
135 | margin: 0;
136 | font-weight: normal;
137 | `;
138 |
139 | export default memo(Navbar);
140 |
--------------------------------------------------------------------------------
/src/components/playground/Playground.tsx:
--------------------------------------------------------------------------------
1 | import Navbar from "@/components/playground/Navbar";
2 | import EditorFallback from "@/components/editor/EditorFallback";
3 | import MiniBrowser from "@/components/output/MiniBrowser";
4 | import VerticalSplitPane from "@/components/ui-elements/VerticalSplitPane";
5 | import useEsbuild from "@/hooks/playground/useEsbuild";
6 | import { VFS } from "@/hooks/playground/useVFS";
7 | import { colors, fixedSizes } from "@/tools/style-tools";
8 | import { generatePayload } from "@/tools/editor-tools";
9 | import {
10 | exportToCodeSandbox,
11 | exportToStackblitz,
12 | exportToZip,
13 | } from "@/tools/exports-tools";
14 | import { useCreateEvento } from "evento-react";
15 | import { lazy, Suspense, useCallback, useEffect } from "react";
16 | import styled from "styled-components";
17 |
18 | const Editor = lazy(() => import("@/components/editor/Editor"));
19 |
20 | interface Props {
21 | initialVFS: VFS | null;
22 | onUpdateVFS: (e: CustomEvent) => void;
23 | }
24 |
25 | function Playground(props: Props) {
26 | const {
27 | addFile,
28 | createBundle,
29 | deleteFile,
30 | editFileContent,
31 | editFileName,
32 | files,
33 | output,
34 | rawImports,
35 | resetVFS,
36 | versionGeneratorRef,
37 | versionRef,
38 | } = useEsbuild(props.initialVFS);
39 |
40 | const evento = useCreateEvento(props);
41 |
42 | const handleAddFile = useCallback((e: CustomEvent) => {
43 | addFile(generatePayload(e.detail));
44 | }, []);
45 |
46 | const handleDeleteFile = useCallback((e: CustomEvent) => {
47 | if (!confirm(`Do you really want to delete ${e.detail}?`)) {
48 | return;
49 | }
50 | deleteFile(generatePayload(e.detail));
51 | }, []);
52 |
53 | const handleEditFileName = useCallback(
54 | ({
55 | detail: { current, next },
56 | }: CustomEvent<{ current: string; next: string }>) => {
57 | editFileName(generatePayload(current, next));
58 | },
59 | [],
60 | );
61 |
62 | const handleExportToCodeSandbox = useCallback(() => {
63 | exportToCodeSandbox(files.fileList, rawImports, files.filesById);
64 | }, [rawImports, files]);
65 |
66 | const handleExportToStackblitz = useCallback(() => {
67 | exportToStackblitz(files.fileList, rawImports, files.filesById);
68 | }, [rawImports, files]);
69 |
70 | const handleExportToZip = useCallback(() => {
71 | exportToZip(files.fileList, rawImports, files.filesById);
72 | }, [rawImports, files]);
73 |
74 | const handleReloadPlayground = useCallback(() => {
75 | if (
76 | !confirm(`If you reload this playground, all of your current changes will be lost.
77 | Do you want to proceed ?`)
78 | ) {
79 | return;
80 | }
81 | resetVFS();
82 | }, []);
83 |
84 | const handleTextEditorChange = useCallback(
85 | ({
86 | detail: { file, text },
87 | }: CustomEvent<{ file: string; text: string }>) => {
88 | editFileContent(generatePayload(file, text));
89 | },
90 | [],
91 | );
92 |
93 | useEffect(() => {
94 | const vfs = files.filesById;
95 |
96 | const timeout = setTimeout(() => {
97 | if (typeof versionRef.current !== "number") {
98 | return;
99 | }
100 | versionRef.current = versionGeneratorRef.current.next().value;
101 | createBundle(vfs, versionRef.current);
102 | }, 300);
103 |
104 | evento("updateVFS", vfs);
105 |
106 | return () => clearTimeout(timeout);
107 | }, [files.filesById]);
108 | return (
109 |
110 |
116 | }>
119 |
126 |
127 | }
128 | rightPaneChild={}
129 | />
130 |
131 | );
132 | }
133 |
134 | const Page = styled.div`
135 | height: calc(100vh - ${fixedSizes.navbarHeight});
136 | width: 100vw;
137 | max-height: 100%;
138 | max-width: 100%;
139 | background-color: ${colors.$bg};
140 | color: ${colors.$silver200};
141 | `;
142 |
143 | export default Playground;
144 |
--------------------------------------------------------------------------------
/src/components/ui-elements/AddButton.tsx:
--------------------------------------------------------------------------------
1 | import AddSVG from "@/components/ui-elements/icons/AddSVG";
2 | import Button from "@/components/ui-elements/Button";
3 | import { colors } from "@/tools/style-tools";
4 | import { useCreateEvento } from "evento-react";
5 | import React, { memo, useCallback } from "react";
6 | import styled from "styled-components";
7 |
8 | interface Props {
9 | onClick: (e: React.MouseEvent) => any;
10 | }
11 |
12 | const AddButton = (props: Props) => {
13 | const evento = useCreateEvento(props);
14 |
15 | const handleClick = useCallback(
16 | (e: React.MouseEvent) => {
17 | evento("click", e);
18 | },
19 | [props],
20 | );
21 |
22 | return (
23 |
26 | );
27 | };
28 |
29 | export default memo(AddButton);
30 |
--------------------------------------------------------------------------------
/src/components/ui-elements/Anchor.tsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const Anchor = styled.a`
4 | color: inherit;
5 | text-decoration: inherit;
6 | cursor: inherit;
7 | `;
8 |
9 | export default Anchor;
10 |
--------------------------------------------------------------------------------
/src/components/ui-elements/BrushButton.tsx:
--------------------------------------------------------------------------------
1 | import BrushSVG from "@/components/ui-elements/icons/BrushSVG";
2 | import Button from "@/components/ui-elements/Button";
3 | import { colors } from "@/tools/style-tools";
4 | import { useCreateEvento } from "evento-react";
5 | import React, { memo, useCallback, type FC } from "react";
6 | import styled from "styled-components";
7 |
8 | interface Props {
9 | onClick: (e: React.MouseEvent) => any;
10 | }
11 |
12 | const BrushButton: FC = (props) => {
13 | const evento = useCreateEvento(props);
14 |
15 | const handleClick = useCallback(
16 | (e: React.MouseEvent) => {
17 | evento("click", e);
18 | },
19 | [props],
20 | );
21 |
22 | return (
23 |
26 | );
27 | };
28 |
29 | export default memo(BrushButton);
30 |
--------------------------------------------------------------------------------
/src/components/ui-elements/Button.tsx:
--------------------------------------------------------------------------------
1 | import { colors } from "@/tools/style-tools";
2 | import { memo } from "react";
3 | import styled from "styled-components";
4 |
5 | const Button = styled.button`
6 | background: none;
7 | border: none;
8 | color: ${colors.$silver200};
9 | min-height: 30px;
10 | min-width: 30px;
11 | margin: 0;
12 | padding: 0;
13 | display: flex;
14 | align-items: center;
15 | justify-content: center;
16 | cursor: pointer;
17 |
18 | &:hover {
19 | color: ${colors.$silver100};
20 | }
21 | `;
22 |
23 | export default memo(Button);
24 |
--------------------------------------------------------------------------------
/src/components/ui-elements/DeleteButton.tsx:
--------------------------------------------------------------------------------
1 | import Button from "@/components/ui-elements/Button";
2 | import CloseSVG from "@/components/ui-elements/icons/CloseSVG";
3 | import { colors } from "@/tools/style-tools";
4 | import { useCreateEvento } from "evento-react";
5 | import React, { memo, useCallback } from "react";
6 | import styled from "styled-components";
7 |
8 | interface Props {
9 | onClick: (e: React.MouseEvent) => any;
10 | }
11 |
12 | const DeleteButton = (props: Props) => {
13 | const evento = useCreateEvento(props);
14 |
15 | const handleClick = useCallback(
16 | (e: React.MouseEvent) => {
17 | evento("click", e);
18 | },
19 | [props],
20 | );
21 |
22 | return (
23 |
24 |
25 |
26 | );
27 | };
28 |
29 | const StyledButton = styled(Button)`
30 | position: absolute;
31 | bottom: 0;
32 | right: 0;
33 | height: 100%;
34 | color: ${colors.$silver300};
35 |
36 | &:hover {
37 | color: ${colors.$red} !important;
38 | }
39 | `;
40 |
41 | export default memo(DeleteButton);
42 |
--------------------------------------------------------------------------------
/src/components/ui-elements/DesktopVerticalSplitPane.tsx:
--------------------------------------------------------------------------------
1 | import ThreeVerticalDotsSVG from "@/components/ui-elements/icons/ThreeVerticalDotsSVG";
2 | import usePreviousValue from "@/hooks/playground/usePreviosValue";
3 | import useWindowSize from "@/hooks/playground/useWindowSize";
4 | import { colors, generalBorderStyle } from "@/tools/style-tools";
5 | import React, { memo, useEffect, useReducer, useRef, useState } from "react";
6 | import styled from "styled-components";
7 |
8 | interface Props {
9 | leftPaneChild?: React.ReactChild;
10 | minWidth?: number;
11 | rightPaneChild?: React.ReactChild;
12 | splitterWidth?: number;
13 | }
14 |
15 | enum ActionKind {
16 | SET_CONTAINER_W = "SET_CONTAINER_W",
17 | SET_IS_MOUSE_DOWN = "SET_IS_MOUSE_DOWN",
18 | SET_INITIAL_L = "SET_INITIAL_L",
19 | SET_LEFT_W = "SET_LEFT_W",
20 | SET_MOUSE_POS = "SET_MOUSE_POS",
21 | SET_RIGHT_W = "SET_RIGHT_W",
22 | }
23 |
24 | interface BooleanAction {
25 | type: ActionKind.SET_IS_MOUSE_DOWN;
26 | payload: boolean;
27 | }
28 |
29 | interface NumberAction {
30 | type: Exclude;
31 | payload: number;
32 | }
33 |
34 | type Action = BooleanAction | NumberAction;
35 |
36 | interface State {
37 | containerW: number;
38 | isMouseDown: boolean;
39 | initialL: number;
40 | leftW: number;
41 | mousePos: number;
42 | rightW: number;
43 | }
44 |
45 | const initialState: State = {
46 | containerW: 0,
47 | initialL: 0,
48 | isMouseDown: false,
49 | leftW: 0,
50 | mousePos: 0,
51 | rightW: 0,
52 | };
53 |
54 | function reducer(state: State, action: Action) {
55 | switch (action.type) {
56 | case ActionKind.SET_CONTAINER_W:
57 | return {
58 | ...state,
59 | containerW: action.payload,
60 | };
61 | case ActionKind.SET_INITIAL_L:
62 | return {
63 | ...state,
64 | initialL: action.payload,
65 | };
66 | case ActionKind.SET_IS_MOUSE_DOWN:
67 | return {
68 | ...state,
69 | isMouseDown: action.payload,
70 | };
71 | case ActionKind.SET_LEFT_W:
72 | return {
73 | ...state,
74 | leftW: action.payload,
75 | };
76 | case ActionKind.SET_MOUSE_POS:
77 | return {
78 | ...state,
79 | mousePos: action.payload,
80 | };
81 | case ActionKind.SET_RIGHT_W:
82 | return {
83 | ...state,
84 | rightW: action.payload,
85 | };
86 | default:
87 | throw new Error();
88 | }
89 | }
90 |
91 | function VerticalSplitPane(props: Props) {
92 | const {
93 | leftPaneChild = null,
94 | minWidth = 200,
95 | splitterWidth = 10,
96 | rightPaneChild = null,
97 | } = props;
98 |
99 | const [
100 | { mousePos, isMouseDown, containerW, initialL, leftW, rightW },
101 | dispatch,
102 | ] = useReducer(reducer, initialState);
103 |
104 | const containerRef = useRef(null);
105 |
106 | const { width: windowW } = useWindowSize();
107 |
108 | const prevWindowW = usePreviousValue(windowW);
109 | const handleMouseDown = (e: React.PointerEvent) => {
110 | dispatch({
111 | type: ActionKind.SET_MOUSE_POS,
112 | payload: e.clientX,
113 | });
114 |
115 | dispatch({
116 | type: ActionKind.SET_INITIAL_L,
117 | payload: leftW,
118 | });
119 |
120 | dispatch({
121 | type: ActionKind.SET_IS_MOUSE_DOWN,
122 | payload: true,
123 | });
124 |
125 | function handleMouseMove(e: MouseEvent) {
126 | const delta = mousePos - e.clientX;
127 | const updatedLeftW =
128 | initialL - delta <= minWidth
129 | ? minWidth
130 | : initialL - delta >= containerW - splitterWidth - minWidth
131 | ? containerW - splitterWidth - minWidth
132 | : initialL - delta;
133 |
134 | const updatedRightW = containerW - updatedLeftW - splitterWidth;
135 |
136 | dispatch({
137 | type: ActionKind.SET_LEFT_W,
138 | payload: updatedLeftW,
139 | });
140 |
141 | dispatch({
142 | type: ActionKind.SET_RIGHT_W,
143 | payload: updatedRightW,
144 | });
145 | }
146 |
147 | function handleMouseUp() {
148 | dispatch({
149 | type: ActionKind.SET_IS_MOUSE_DOWN,
150 | payload: false,
151 | });
152 | window.removeEventListener("mousemove", handleMouseMove);
153 | window.removeEventListener("mouseup", handleMouseUp);
154 | }
155 |
156 | window.addEventListener("mousemove", handleMouseMove);
157 | window.addEventListener("mouseup", handleMouseUp);
158 | };
159 |
160 | useEffect(() => {
161 | const initialContainerW = containerRef?.current?.clientWidth;
162 |
163 | if (typeof initialContainerW === "number") {
164 | const initialLeftW = (initialContainerW - splitterWidth) / 2;
165 | const initialRightW = (initialContainerW - splitterWidth) / 2;
166 |
167 | dispatch({
168 | type: ActionKind.SET_CONTAINER_W,
169 | payload: initialContainerW,
170 | });
171 | dispatch({
172 | type: ActionKind.SET_LEFT_W,
173 | payload: initialLeftW < minWidth ? minWidth : initialLeftW,
174 | });
175 | dispatch({
176 | type: ActionKind.SET_RIGHT_W,
177 | payload: initialRightW < minWidth ? minWidth : initialRightW,
178 | });
179 | }
180 | }, []);
181 |
182 | useEffect(() => {
183 | const currContainerW = containerRef?.current?.clientWidth;
184 |
185 | if (
186 | typeof windowW !== "number" ||
187 | typeof prevWindowW !== "number" ||
188 | typeof currContainerW !== "number" ||
189 | windowW === prevWindowW
190 | ) {
191 | return;
192 | }
193 |
194 | const leftRatio = leftW / (leftW + rightW - splitterWidth / 2);
195 | const tempLevtW = currContainerW * leftRatio - splitterWidth / 2;
196 |
197 | dispatch({
198 | type: ActionKind.SET_CONTAINER_W,
199 | payload: currContainerW,
200 | });
201 |
202 | dispatch({
203 | type: ActionKind.SET_LEFT_W,
204 | payload: tempLevtW,
205 | });
206 |
207 | dispatch({
208 | type: ActionKind.SET_RIGHT_W,
209 | payload: currContainerW - tempLevtW - splitterWidth / 2,
210 | });
211 | }, [containerW, prevWindowW, leftW, rightW, windowW]);
212 |
213 | return (
214 |
218 |
219 | {isMouseDown && }
220 | {leftPaneChild}
221 |
222 |
227 |
228 |
229 |
230 | {isMouseDown && }
231 | {rightPaneChild}
232 |
233 |
234 | );
235 | }
236 |
237 | const Container = styled.section`
238 | position: relative;
239 | height: 100%;
240 | width: 100%;
241 | display: flex;
242 |
243 | &.diableSelect,
244 | &.disableSelect * {
245 | user-select: none;
246 | -webkit-user-select: none;
247 | -khtml-user-select: none;
248 | -moz-user-select: none;
249 | -ms-user-select: none;
250 | cursor: col-resize;
251 | }
252 | `;
253 |
254 | const Pane = styled.div`
255 | height: 100%;
256 | `;
257 |
258 | const Splitter = styled.div`
259 | height: 100%;
260 | display: grid;
261 | place-content: center;
262 | background-color: ${colors.$bg};
263 | cursor: col-resize;
264 | color: ${colors.$silver200};
265 | border-left: ${generalBorderStyle};
266 | border-right: ${generalBorderStyle};
267 |
268 | &:hover,
269 | &.in-use {
270 | background-color: ${colors.$purple};
271 | }
272 | `;
273 |
274 | const WindowHook = styled.div`
275 | position: absolute;
276 | top: 0;
277 | bottom: 0;
278 | right: 0;
279 | left: 0;
280 | `;
281 |
282 | export default memo(VerticalSplitPane);
283 |
--------------------------------------------------------------------------------
/src/components/ui-elements/Loader.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from "react";
2 |
3 | const Loader = () => {
4 | return Loading...
;
5 | };
6 |
7 | export default memo(Loader);
8 |
--------------------------------------------------------------------------------
/src/components/ui-elements/MobileVerticalSplitPane.tsx:
--------------------------------------------------------------------------------
1 | import { colors } from "@/tools/style-tools";
2 | import React, { memo, useCallback, useState } from "react";
3 | import styled from "styled-components";
4 | import ExpandSVG from "./icons/ExpandSVG";
5 |
6 | interface Props {
7 | leftPaneChild?: React.ReactChild;
8 | rightPaneChild?: React.ReactChild;
9 | }
10 |
11 | enum Tabs {
12 | LEFT = "Editor",
13 | RIGHT = "Result",
14 | }
15 |
16 | function MobileTabDisplayer(props: Props) {
17 | const { leftPaneChild = null, rightPaneChild = null } = props;
18 |
19 | const [currTab, setCurrtab] = useState(Tabs.LEFT);
20 |
21 | const handleChange = useCallback((e: React.ChangeEvent) => {
22 | setCurrtab(e.target.checked ? Tabs.LEFT : Tabs.RIGHT);
23 | }, []);
24 |
25 | const showEditor = currTab === Tabs.LEFT;
26 |
27 | return (
28 |
29 | {showEditor ? leftPaneChild : rightPaneChild}
30 |
31 |
50 |
51 |
52 | );
53 | }
54 |
55 | const Container = styled.section`
56 | position: relative;
57 | height: 100%;
58 | width: 100%;
59 | `;
60 |
61 | const Pane = styled.div`
62 | height: calc(100% - 35px);
63 | width: 100%;
64 | `;
65 |
66 | const SwithcSection = styled.form`
67 | position: sticky;
68 | bottom: 0;
69 | height: 35px;
70 | width: 100%;
71 | display: flex;
72 | align-items: center;
73 | justify-content: center;
74 | background-color: ${colors.$purple};
75 | color: ${colors.$silver100};
76 | `;
77 |
78 | const Label = styled.label`
79 | cursor: pointer;
80 | display: flex;
81 | align-items: center;
82 | `;
83 |
84 | const TabCheckbox = styled.input`
85 | display: none;
86 | `;
87 |
88 | export default memo(MobileTabDisplayer);
89 |
--------------------------------------------------------------------------------
/src/components/ui-elements/Tooltip.tsx:
--------------------------------------------------------------------------------
1 | import React, { type FC, type PropsWithChildren } from "react";
2 |
3 | interface Props {
4 | text: string;
5 | }
6 |
7 | const Tooltip: FC> = ({ children, text }) => (
8 | {children}
9 | );
10 |
11 | export default Tooltip;
12 |
--------------------------------------------------------------------------------
/src/components/ui-elements/VerticalSplitPane.tsx:
--------------------------------------------------------------------------------
1 | import { responsiveBreakpoint } from "@/tools/style-tools";
2 | import useWindowSize from "@/hooks/playground/useWindowSize";
3 | import React, { memo } from "react";
4 | import MobileVerticalSplitPane from "@/components/ui-elements/MobileVerticalSplitPane";
5 | import DesktopVerticalSplitPane from "@/components/ui-elements/DesktopVerticalSplitPane";
6 |
7 | interface Props {
8 | leftPaneChild?: React.ReactChild;
9 | minWidth?: number;
10 | rightPaneChild?: React.ReactChild;
11 | splitterWidth?: number;
12 | }
13 |
14 | function PanesResponsivityHandler(props: Props) {
15 | const { leftPaneChild, rightPaneChild } = props;
16 |
17 | const { width: windowW } = useWindowSize();
18 |
19 | return typeof windowW === "number" && windowW > responsiveBreakpoint ? (
20 |
21 | ) : (
22 |
26 | );
27 | }
28 |
29 | export default memo(PanesResponsivityHandler);
30 |
--------------------------------------------------------------------------------
/src/components/ui-elements/icons/AddSVG.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | height: string;
3 | width: string;
4 | }
5 |
6 | const AddSVG = (props: Props) => (
7 |
15 | );
16 |
17 | export default AddSVG;
18 |
--------------------------------------------------------------------------------
/src/components/ui-elements/icons/BrushSVG.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | height: string;
3 | width: string;
4 | }
5 |
6 | const BrushSVG = (props: Props) => (
7 |
15 | );
16 |
17 | export default BrushSVG;
18 |
--------------------------------------------------------------------------------
/src/components/ui-elements/icons/BugSVG.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | height: string;
3 | width: string;
4 | }
5 |
6 | const BugSVG = (props: Props) => (
7 |
15 | );
16 |
17 | export default BugSVG;
18 |
--------------------------------------------------------------------------------
/src/components/ui-elements/icons/CSSLogoSVG.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | height: string;
3 | width: string;
4 | }
5 |
6 | const CSSLogoSVG = (props: Props) => (
7 |
22 | );
23 |
24 | export default CSSLogoSVG;
25 |
--------------------------------------------------------------------------------
/src/components/ui-elements/icons/CloseSVG.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | height: string;
3 | width: string;
4 | }
5 |
6 | const CloseSVG = (props: Props) => (
7 |
15 | );
16 |
17 | export default CloseSVG;
18 |
--------------------------------------------------------------------------------
/src/components/ui-elements/icons/CodeSanboxLogoSVG.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | height: string;
3 | width: string;
4 | }
5 |
6 | const CodeSandboxLogoSVG = (props: Props) => (
7 |
20 | );
21 |
22 | export default CodeSandboxLogoSVG;
23 |
--------------------------------------------------------------------------------
/src/components/ui-elements/icons/DownloadSVG.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | height: string;
3 | width: string;
4 | }
5 |
6 | const DownloadSVG = (props: Props) => (
7 |
15 | );
16 |
17 | export default DownloadSVG;
18 |
--------------------------------------------------------------------------------
/src/components/ui-elements/icons/ExpandSVG.tsx:
--------------------------------------------------------------------------------
1 | import { transitionDuration } from "@/tools/style-tools";
2 | import styled from "styled-components";
3 |
4 | interface Props {
5 | direction: "down" | "left" | "right" | "up";
6 | height: string;
7 | width: string;
8 | }
9 |
10 | const ExpandSVG = (props: Props) => {
11 | const { direction, ...otherProps } = props;
12 |
13 | return (
14 |
23 | );
24 | };
25 |
26 | const SVG = styled.svg`
27 | transition: ${transitionDuration};
28 |
29 | &.down {
30 | transform: rotate(180deg);
31 | }
32 |
33 | &.left {
34 | transform: rotate(-90deg);
35 | }
36 |
37 | &.right {
38 | transform: rotate(90deg);
39 | }
40 |
41 | &.up {
42 | transform: rotate(0deg);
43 | }
44 | `;
45 |
46 | export default ExpandSVG;
47 |
--------------------------------------------------------------------------------
/src/components/ui-elements/icons/JavaScripLogoSVG.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | height: string;
3 | width: string;
4 | }
5 |
6 | const JavaScripLogoSVG = (props: Props) => (
7 |
11 | );
12 |
13 | export default JavaScripLogoSVG;
14 |
--------------------------------------------------------------------------------
/src/components/ui-elements/icons/ReactLogoSVG.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | height: string;
3 | width: string;
4 | }
5 |
6 | const ReactLogoSVG = (props: Props) => (
7 |
14 | );
15 |
16 | export default ReactLogoSVG;
17 |
--------------------------------------------------------------------------------
/src/components/ui-elements/icons/ReactPlaygroundLogoSVG.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | height: string;
3 | width: string;
4 | }
5 |
6 | const ReactPlaygroundLogoSVG = (props: Props) => (
7 |
39 | );
40 |
41 | export default ReactPlaygroundLogoSVG;
42 |
--------------------------------------------------------------------------------
/src/components/ui-elements/icons/RefreshSVG.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | height: string;
3 | width: string;
4 | }
5 |
6 | const RefreshSVG = (props: Props) => (
7 |
15 | );
16 |
17 | export default RefreshSVG;
18 |
--------------------------------------------------------------------------------
/src/components/ui-elements/icons/ShareSVG.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | height: string;
3 | width: string;
4 | }
5 |
6 | const ShareSVG = (props: Props) => (
7 |
15 | );
16 |
17 | export default ShareSVG;
18 |
--------------------------------------------------------------------------------
/src/components/ui-elements/icons/StackblitzLogoSVG.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | height: string;
3 | width: string;
4 | }
5 |
6 | const StackblitzLogoSVG = (props: Props) => (
7 |
18 | );
19 |
20 | export default StackblitzLogoSVG;
21 |
--------------------------------------------------------------------------------
/src/components/ui-elements/icons/ThreeVerticalDotsSVG.tsx:
--------------------------------------------------------------------------------
1 | interface Props {
2 | height: string;
3 | width: string;
4 | }
5 |
6 | const ThreeVerticalDotsSVG = (props: Props) => (
7 |
15 | );
16 |
17 | export default ThreeVerticalDotsSVG;
18 |
--------------------------------------------------------------------------------
/src/contexts/URLStateContext.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | createContext,
3 | useCallback,
4 | useContext,
5 | useSyncExternalStore,
6 | type FC,
7 | type PropsWithChildren,
8 | } from "react";
9 | import { URLStateUseCases } from "@/useCases";
10 | import { URLStorageRepositoryImpl } from "@/repositories";
11 | import {
12 | URLStateEntity,
13 | type ParsedV2,
14 | } from "@/entities";
15 | import { useCreateStore, type Store } from "@/tools/context-tools";
16 | import { ClipboardRepositoryImpl } from "@/repositories";
17 |
18 | const urlStorageRepository = new URLStorageRepositoryImpl();
19 | const clipboardRepository = new ClipboardRepositoryImpl();
20 | const urlStateUseCases = new URLStateUseCases({
21 | clipboardRepository,
22 | urlStorageRepository,
23 | });
24 |
25 | const URLStateContext = createContext | null>(null);
26 |
27 | export const URLStateProvider: FC = ({ children }) => {
28 | const store = useCreateStore(urlStateUseCases.getURLState());
29 |
30 | return (
31 |
32 | {children}
33 |
34 | );
35 | };
36 |
37 | export function useURLState({
38 | // have to figure the lazy part out yet
39 | lazy = false,
40 | selector = (state) => state as SelectorOutput,
41 | }: {
42 | lazy?: boolean;
43 | selector?: (state: URLStateEntity) => SelectorOutput;
44 | } = {}) {
45 | const store = useContext(URLStateContext);
46 | if (!store) {
47 | throw new Error("useURLStateContext must be used within a URLStateContext");
48 | }
49 |
50 | const getURLState = useCallback(() => urlStateUseCases.getURLState(), []);
51 |
52 | const updateURLState = useCallback<(urlState: ParsedV2) => void>(
53 | (urlState) => urlStateUseCases.updateURL(urlState),
54 | []
55 | );
56 |
57 | const copyURLToClipboard = useCallback(
58 | () => urlStateUseCases.copyURLToClipboard(),
59 | []
60 | );
61 |
62 | const state = useSyncExternalStore(store.subscribe, () =>
63 | selector(store.get())
64 | );
65 |
66 | const update = useCallback(() => {
67 | store.set(urlStateUseCases.getURLState());
68 | }, []);
69 |
70 | return [
71 | state,
72 | { getURLState, updateURLState, copyURLToClipboard },
73 | update,
74 | ] as const;
75 | }
76 |
77 | export function vfsFromURLSelector(
78 | state: URLStateEntity
79 | ): ReturnType {
80 | return urlStateUseCases.extractVFSFromURL(state.parsed);
81 | }
82 |
--------------------------------------------------------------------------------
/src/entities/URLStateEntity.ts:
--------------------------------------------------------------------------------
1 | export type ParsedV1 = Record;
2 | export type ParsedV2 = { ts: boolean; vfs: Record };
3 |
4 | export class URLStateEntity {
5 | public parsed: ParsedV1 | ParsedV2 | null;
6 | public urlString: string | null;
7 |
8 | constructor(
9 | urlString: string | null,
10 | parsed: { ts: boolean; vfs: Record } | null,
11 | ) {
12 | this.parsed = parsed;
13 | this.urlString = urlString;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/entities/VFSStateEntity.ts:
--------------------------------------------------------------------------------
1 | type VFSKey = string &
2 | (Ts extends true
3 | ? "index.tsx)" | `${string}.${"css" | "ts" | "tsx"}`
4 | : "index.js)" | `${string}.${"css" | "js" | "jsx"}`);
5 |
6 | export class VFSStateEntity {
7 | ts: Ts;
8 | filesList: VFSKey[];
9 | vfs: Record, string>;
10 |
11 | constructor(ts: Ts, vfs: Record, string>) {
12 | const canCreate = VFSStateEntity.ensureCorrectFileExtensions(ts, vfs);
13 | if (!canCreate) throw new Error("Missing index file)");
14 |
15 | this.ts = ts;
16 | this.vfs = vfs;
17 | this.filesList = Object.keys(vfs) as VFSKey[];
18 | }
19 |
20 | static ensureCorrectFileExtensions(
21 | ts: Ts,
22 | vfs: Record,
23 | ): vfs is Record, string> {
24 | if (ts && !vfs["index.tsx)"]) throw new Error("Missing index.tsx)");
25 | if (!ts && !vfs["index.js)"]) throw new Error("Missing index.js)");
26 |
27 | return true;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/entities/index.ts:
--------------------------------------------------------------------------------
1 | export { URLStateEntity, type ParsedV1, type ParsedV2 } from './URLStateEntity'
2 | export { VFSStateEntity } from './VFSStateEntity'
--------------------------------------------------------------------------------
/src/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
9 |
--------------------------------------------------------------------------------
/src/hooks/playground/editor/setupMonacoEditor.ts:
--------------------------------------------------------------------------------
1 | import { Editor } from "@monaco-editor/react";
2 | import { JsxEmit } from "typescript";
3 | import { useEffect, useState, type ComponentProps } from "react";
4 | import { createATA } from "@/tools/editor-tools";
5 |
6 | const defaultContent = `
7 | import { useStatus as useState } from './test'
8 | import { ref } from 'vue'
9 | import _ from 'lodash'
10 |
11 | // vue
12 | export const count = ref(10);
13 |
14 | // react
15 | export function useCounter() {
16 | const [count, setCount] = useState(0)
17 | return { count, increment: () => setCount(count + 1) }
18 | }
19 |
20 | // lodash
21 | const res = _.partition([1, 2, 3, 4], n => n % 2);
22 |
23 | // 1. hover the cursor on those variables above to see the types.
24 | // 2. try to import any other library, the types will be automatically loaded.
25 | `;
26 |
27 | export const typeHelper = createATA();
28 |
29 | export function useProgress() {
30 | const [progress, setProgress] = useState(0);
31 | const [total, setTotal] = useState(0);
32 | const [finished, setFinished] = useState(false);
33 |
34 | useEffect(() => {
35 | const handleProgress = (progress: number, total: number) => {
36 | setProgress(progress);
37 | setTotal(total);
38 | };
39 | typeHelper.addListener("progress", handleProgress);
40 |
41 | const handleFinished = () => setFinished(true);
42 | typeHelper.addListener("finished", handleFinished);
43 |
44 | const handleStarted = () => setFinished(false);
45 | typeHelper.addListener("started", handleStarted);
46 |
47 | return () => {
48 | typeHelper.removeListener("progress", handleProgress);
49 | typeHelper.removeListener("finished", handleFinished);
50 | typeHelper.removeListener("started", handleStarted);
51 | };
52 | }, []);
53 |
54 | return { progress, total, finished };
55 | }
56 |
57 | const testCode = `
58 | import { useState } from 'react'
59 | export const useStatus = useState
60 | export function useCounter() {
61 | const [count, setCount] = useState(0)
62 | return { count, increment: () => setCount(count + 1) }
63 | }
64 | `;
65 |
66 | export const setupEditor: NonNullable<
67 | ComponentProps["onMount"]
68 | > = (editor, monaco) => {
69 | // acquireType on initial load
70 | editor.onDidChangeModelContent(() => {
71 | typeHelper.acquireType(editor.getValue());
72 | });
73 |
74 | const defaults = monaco.languages.typescript.typescriptDefaults;
75 |
76 | defaults.setCompilerOptions({
77 | jsx: JsxEmit.React,
78 | esModuleInterop: true,
79 | });
80 | defaults.addExtraLib(testCode, "file:///test.tsx");
81 |
82 | const addLibraryToRuntime = (code: string, _path: string) => {
83 | const path = "file://" + _path;
84 | defaults.addExtraLib(code, path);
85 |
86 | // don't need to open the file in the editor
87 | // const uri = monaco.Uri.file(path);
88 | // if (monaco.editor.getModel(uri) === null) {
89 | // monaco.editor.createModel(code, 'javascript', uri);
90 | // }
91 | };
92 |
93 | typeHelper.addListener("receivedFile", addLibraryToRuntime);
94 |
95 | typeHelper.acquireType(defaultContent);
96 | console.log("defaults, ", defaults);
97 | // auto adjust the height fits the content
98 | const element = editor.getDomNode();
99 | const height = editor.getScrollHeight();
100 | if (element) {
101 | element.style.height = `${height}px`;
102 | }
103 | };
104 |
--------------------------------------------------------------------------------
/src/hooks/playground/editor/useCodeFormatter.ts:
--------------------------------------------------------------------------------
1 | import Worker from "@/workers/codeFormatter/codeFormatter.worker?worker";
2 | import { type FormatResponseData } from "@/workers/codeFormatter/codeFormatter.worker";
3 | import { useCallback, useEffect, useRef } from "react";
4 | import { AcceptedFileType } from "@/tools/esbuild-tools";
5 |
6 | interface FormatParamOpts {
7 | code: string;
8 | lang: AcceptedFileType;
9 | onComplete: (code: string) => void;
10 | onError?: (err: Error) => void;
11 | }
12 | export function useCodeFormatter() {
13 | const workerRef = useRef();
14 | const onCompleteRef = useRef<(code: string) => void>(() => {});
15 | const onErrorRef = useRef void)>(null);
16 |
17 | const format = useCallback(
18 | ({ code, lang, onComplete, onError }: FormatParamOpts) => {
19 | workerRef.current?.postMessage({ type: "code", data: { code, lang } });
20 | onCompleteRef.current = onComplete;
21 | if (onError) {
22 | onErrorRef.current = onError;
23 | }
24 | },
25 | [workerRef, onCompleteRef, onErrorRef],
26 | );
27 | useEffect(() => {
28 | workerRef.current = new Worker();
29 |
30 | workerRef.current.onmessage = (event: { data: FormatResponseData }) => {
31 | const { type, data, error } = event.data;
32 |
33 | if (type === "code") {
34 | onCompleteRef.current(data);
35 | }
36 |
37 | if (type === "error") {
38 | onCompleteRef.current(data);
39 | onErrorRef.current?.(error);
40 | }
41 | };
42 |
43 | return () => {
44 | workerRef.current?.terminate();
45 | };
46 | }, []);
47 |
48 | return format;
49 | }
50 |
--------------------------------------------------------------------------------
/src/hooks/playground/editor/useTabsScroller.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from "react";
2 |
3 | export function useTabsScroller({ tabs }: { tabs: string[] }) {
4 | const [isOverflowedLeft, setIsOverflowedLeft] = useState(false);
5 | const [isOverflowedRight, setIsOverflowedRight] = useState(false);
6 | const tabsRef = useRef(null);
7 | const containerRef = useRef(null);
8 |
9 | const calculateFaders = useCallback(() => {
10 | const { current: ul } = tabsRef;
11 |
12 | if (ul) {
13 | const { scrollWidth, clientWidth, scrollLeft } = ul;
14 | setIsOverflowedLeft(scrollLeft > 0);
15 | setIsOverflowedRight(scrollLeft < scrollWidth - clientWidth);
16 | }
17 | }, [setIsOverflowedLeft, setIsOverflowedRight, tabsRef]);
18 |
19 | useEffect(() => {
20 | calculateFaders();
21 | const { current: container } = containerRef;
22 | const observer = new ResizeObserver((entries) => {
23 | for (const entry of entries) {
24 | calculateFaders();
25 | }
26 | });
27 | if (container) observer.observe(container);
28 |
29 | return () => {
30 | if (container) observer.unobserve(container);
31 | };
32 | }, [calculateFaders, tabs, tabsRef, containerRef]);
33 |
34 | return {
35 | containerRef,
36 | tabsRef,
37 | isOverflowedLeft,
38 | isOverflowedRight,
39 | handleScroll: calculateFaders,
40 | };
41 | }
42 |
--------------------------------------------------------------------------------
/src/hooks/playground/useEsbuild.ts:
--------------------------------------------------------------------------------
1 | import useVFS, { ENTRY_POINT_JSX, VFS } from "@/hooks/playground/useVFS";
2 | import { BundleError, createErrorString } from "@/tools/esbuild-tools";
3 | import { countGen } from "@/tools/editor-tools";
4 | import { initialLoader } from "@/tools/iframe-tools";
5 | import { RawImports } from "@/tools/exports-tools";
6 | import * as esbuild from "esbuild-wasm";
7 | import axios from "axios";
8 | import localforage from "localforage";
9 | import { useCallback, useEffect, useRef, useState } from "react";
10 |
11 | interface OutputTypeSuccess {
12 | code: string;
13 | error: null;
14 | }
15 |
16 | interface OutputTypeFail {
17 | code: null;
18 | error: string;
19 | }
20 |
21 | export type OutputType = OutputTypeSuccess | OutputTypeFail;
22 |
23 | export const CDN = "https://esm.sh";
24 |
25 | export const make_CDN_URL = (pkg: string) => `${CDN}/${pkg}`;
26 |
27 | const fileCache = localforage.createInstance({
28 | name: "filecache",
29 | });
30 |
31 | const make_css_contents = (originalCSS: string) => {
32 | const escapedCSS = originalCSS
33 | .replace(/\n/g, "")
34 | .replace(/"/g, '\\"')
35 | .replace(/'/g, "\\'");
36 |
37 | const CSSContents = `
38 | const styleTag = document.createElement('style')
39 | styleTag.innerText = '${escapedCSS}'
40 | document.head.appendChild(styleTag)
41 | `.trim();
42 |
43 | return CSSContents;
44 | };
45 |
46 | export default function useEsbuild(vfsFromUrl: VFS | null) {
47 | const [bundleJSXText, setBundleJSXText] = useState(
48 | initialLoader,
49 | );
50 | const [bundleErr, setBundleErr] = useState(null);
51 | const [rawImports, setRawImports] = useState({});
52 |
53 | const {
54 | addFile,
55 | deleteFile,
56 | editFileContent,
57 | editFileName,
58 | fileList,
59 | vfs,
60 | resetVFS,
61 | } = useVFS(vfsFromUrl);
62 |
63 | const esbuildRef = useRef(esbuild);
64 | const isEsbuildInitializedRef = useRef(false);
65 | const versionGeneratorRef = useRef>(countGen());
66 | const versionRef = useRef(versionGeneratorRef.current.next().value);
67 |
68 | const vfs_with_esm_sh_plugin = useCallback((vfs: VFS) => {
69 | return {
70 | name: "vfs-with-esm-sh-plugin",
71 | setup(build: esbuild.PluginBuild) {
72 | build.onResolve({ filter: /.*/ }, async (args: any) => {
73 | if (args.path === ENTRY_POINT_JSX) {
74 | return { path: args.path, namespace: "a" };
75 | }
76 |
77 | if (args.path.startsWith("./") && vfs[args.path.substring(2)]) {
78 | return {
79 | namespace: "a",
80 | path: args.path.substring(2),
81 | };
82 | }
83 |
84 | if (
85 | args.path.startsWith("./") &&
86 | vfs[`${args.path.substring(2)}.js`]
87 | ) {
88 | return {
89 | namespace: "a",
90 | path: `${args.path.substring(2)}.js`,
91 | };
92 | }
93 |
94 | if (
95 | args.path.startsWith("./") &&
96 | vfs[`${args.path.substring(2)}.jsx`]
97 | ) {
98 | return {
99 | namespace: "a",
100 | path: `${args.path.substring(2)}.jsx`,
101 | };
102 | }
103 |
104 | if (args.path.startsWith(CDN)) {
105 | return {
106 | namespace: "b",
107 | path: args.path,
108 | };
109 | }
110 |
111 | if (args.path.includes("./") || args.path.includes("../")) {
112 | return {
113 | namespace: "b",
114 | path: new URL(args.path, CDN + args.resolveDir + "/").href,
115 | };
116 | }
117 |
118 | if (args.path.startsWith("/")) {
119 | return {
120 | namespace: "b",
121 | //@ts-ignore: defineHack is defined in index.html
122 | path: `${CDN}${args.path}`,
123 | };
124 | }
125 |
126 | return {
127 | namespace: "b",
128 | //@ts-ignore: defineHack is defined in index.html
129 | path: make_CDN_URL(args.path),
130 | };
131 | });
132 |
133 | build.onLoad({ filter: /.css$/ }, async (args: any) => {
134 | const contents = make_css_contents(
135 | vfs[args.path] ? vfs[args.path] : "",
136 | );
137 |
138 | const result: esbuild.OnLoadResult = {
139 | loader: "jsx",
140 | contents,
141 | };
142 |
143 | return result;
144 | });
145 |
146 | build.onLoad({ filter: /.*/ }, async (args: any) => {
147 | if (args.path === ENTRY_POINT_JSX) {
148 | return {
149 | loader: "jsx",
150 | contents: vfs[ENTRY_POINT_JSX],
151 | };
152 | }
153 |
154 | if (vfs[args.path]) {
155 | return {
156 | loader: "jsx",
157 | contents: vfs[args.path],
158 | };
159 | }
160 |
161 | const cached = await fileCache.getItem(
162 | args.path,
163 | );
164 |
165 | if (cached) {
166 | return cached;
167 | }
168 |
169 | const { data, request } = await axios.get(args.path);
170 | const result: esbuild.OnLoadResult = {
171 | loader: "jsx",
172 | contents: data,
173 | resolveDir: new URL("./", request.responseURL).pathname,
174 | };
175 |
176 | await fileCache.setItem(args.path, result);
177 |
178 | return result;
179 | });
180 | },
181 | };
182 | }, []);
183 |
184 | const createBundle = useCallback(async (vfs: VFS, prevVersion: number) => {
185 | if (
186 | !isEsbuildInitializedRef.current ||
187 | typeof versionRef.current !== "number"
188 | ) {
189 | return;
190 | }
191 | try {
192 | const bundle = await esbuildRef.current.build({
193 | bundle: true,
194 | entryPoints: [ENTRY_POINT_JSX],
195 | format: "esm",
196 | metafile: true,
197 | write: false,
198 | plugins: [vfs_with_esm_sh_plugin(vfs)],
199 | // @ts-ignore, this is necessary because vite will automatically escape and replace the string "process.env.NODE_ENV"
200 | define: window.defineHack,
201 | });
202 | const bundleJSX = bundle?.outputFiles?.[0]?.text;
203 | const _imports = bundle?.metafile?.inputs;
204 | if (prevVersion < versionRef.current) {
205 | return;
206 | }
207 | setBundleJSXText(bundleJSX);
208 | setBundleErr(null);
209 | setRawImports(_imports);
210 | } catch (err) {
211 | if (prevVersion < versionRef.current) {
212 | return;
213 | }
214 |
215 | setBundleJSXText(null);
216 | setBundleErr(createErrorString(err as BundleError));
217 | }
218 | }, []);
219 |
220 | useEffect(() => {
221 | try {
222 | esbuildRef.current
223 | .initialize({
224 | wasmURL: "/esbuild.wasm", // 'https://unpkg.com/esbuild-wasm@0.8.27/esbuild.wasm' //
225 | })
226 | .then(() => {
227 | isEsbuildInitializedRef.current = true;
228 | createBundle(vfs, versionRef.current);
229 | });
230 | } catch {
231 | createBundle(vfs, versionRef.current);
232 | }
233 |
234 | function clearDB() {
235 | localforage.clear();
236 | }
237 |
238 | return () => clearDB();
239 | }, []);
240 |
241 | return {
242 | addFile,
243 | createBundle,
244 | deleteFile,
245 | editFileContent,
246 | editFileName,
247 | files: {
248 | fileList,
249 | filesById: vfs,
250 | },
251 | output: {
252 | code: typeof bundleJSXText === "string" ? bundleJSXText : null,
253 | error: typeof bundleJSXText === "string" ? null : bundleErr,
254 | } as OutputType,
255 | rawImports,
256 | resetVFS,
257 | versionGeneratorRef,
258 | versionRef,
259 | };
260 | }
261 |
--------------------------------------------------------------------------------
/src/hooks/playground/usePreviosValue.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 |
3 | function usePreviousValue(value: T): T {
4 | const ref: any = useRef();
5 |
6 | useEffect(() => {
7 | ref.current = value;
8 | }, [value]);
9 |
10 | return ref.current;
11 | }
12 |
13 | export default usePreviousValue;
14 |
--------------------------------------------------------------------------------
/src/hooks/playground/useURLStorage.ts:
--------------------------------------------------------------------------------
1 | import { copyToClipboard } from "@/tools/clipboard-tools";
2 | import {
3 | compressToEncodedURIComponent as compress,
4 | decompressFromEncodedURIComponent as decompress,
5 | } from "lz-string";
6 | import { ENTRY_POINT_JSX, VFS } from "@/hooks/playground/useVFS";
7 | import { useCallback, useMemo } from "react";
8 |
9 | export default function useURLStorage() {
10 | const initialVFS = useMemo(() => {
11 | const url = new URL(location.href);
12 | const { hash } = url;
13 | const vfsString = decompress(hash.slice(1));
14 | let vfs: VFS = {};
15 |
16 | if (typeof vfsString === "string") {
17 | try {
18 | vfs = JSON.parse(vfsString);
19 | } catch {
20 | console.error(
21 | "There is a problem with the URL, we will generate a new project from scratch.",
22 | );
23 | }
24 | }
25 |
26 | return vfs[ENTRY_POINT_JSX] ? vfs : null;
27 | }, []);
28 |
29 | const updateURL = useCallback((vfs: VFS) => {
30 | const url = new URL(location.href);
31 | url.hash = compress(JSON.stringify(vfs));
32 | history.replaceState({}, "", url.toString());
33 | }, []);
34 |
35 | const copyURLToClipBoard = useCallback(
36 | (): Promise => copyToClipboard(location.href),
37 | [],
38 | );
39 |
40 | return {
41 | copyURLToClipBoard,
42 | initialVFS,
43 | updateURL,
44 | };
45 | }
46 |
--------------------------------------------------------------------------------
/src/hooks/playground/useVFS.ts:
--------------------------------------------------------------------------------
1 | import { generatePayload } from "@/tools/editor-tools";
2 | import { useCallback, useReducer } from "react";
3 |
4 | enum ActionKind {
5 | ADD_FILE = "ADD_FILE",
6 | DELETE_FILE = "DELETE_FILE",
7 | EDIT_FILE_CONTENT = " EDIT_FILE_CONTENT",
8 | EDIT_FILE_NAME = "EDIT_FILE_NAME",
9 | RESET_VFS = "RESET_VFS",
10 | }
11 |
12 | interface ActionPayload {
13 | target: string;
14 | content: string;
15 | }
16 |
17 | interface Action {
18 | type: ActionKind;
19 | payload: ActionPayload;
20 | }
21 |
22 | export interface VFS {
23 | [key: string]: string;
24 | }
25 |
26 | interface State {
27 | fileList: string[];
28 | vfs: VFS;
29 | }
30 |
31 | export const ENTRY_POINT_JSX = "index.js";
32 |
33 | const indexDefaultContent = `
34 | import App from './App.jsx'
35 | import React from 'react'
36 | import { createRoot } from 'react-dom/client'
37 |
38 | const container = document.getElementById('root')
39 | const root = createRoot(container)
40 | root.render(
41 | React.createElement(
42 | React.StrictMode,
43 | null,
44 | React.createElement(App)
45 | )
46 | )
47 | `.trim();
48 |
49 | const AppDefaultContent = `
50 | import React, { useRef, useState } from 'react'
51 | import Confetti from 'js-confetti'
52 | import './style.css'
53 |
54 | const confetti = new Confetti()
55 |
56 | const App = () => {
57 | const [count, setCount] = useState(0)
58 |
59 | const handleClick = () => {
60 | confetti.addConfetti()
61 | setCount(c => c + 1)
62 | }
63 |
64 | return (
65 |
68 | )
69 | }
70 |
71 |
72 | export default App
73 | `.trim();
74 |
75 | const StylesDefaultContent = `
76 | .btn {
77 | font-size: 2rem;
78 | }
79 | `.trim();
80 |
81 | const defaultState: State = {
82 | fileList: [ENTRY_POINT_JSX, "App.jsx"],
83 | vfs: {
84 | [ENTRY_POINT_JSX]: indexDefaultContent,
85 | "App.jsx": AppDefaultContent,
86 | "style.css": StylesDefaultContent,
87 | },
88 | };
89 |
90 | function init(vfsFromUrl: VFS | null): State {
91 | if (!vfsFromUrl) {
92 | return defaultState;
93 | }
94 |
95 | if (!vfsFromUrl[ENTRY_POINT_JSX]) {
96 | return defaultState;
97 | }
98 |
99 | let tabs = Object.keys(vfsFromUrl);
100 |
101 | if (tabs.indexOf(ENTRY_POINT_JSX) !== 0) {
102 | tabs = [ENTRY_POINT_JSX, ...tabs.filter((tab) => tab !== ENTRY_POINT_JSX)];
103 | }
104 |
105 | const derivedState = {
106 | directImports: [],
107 | fileList: tabs,
108 | versionedImports: {},
109 | vfs: vfsFromUrl,
110 | };
111 |
112 | return derivedState;
113 | }
114 |
115 | function reducer(state: State, action: Action): State {
116 | switch (action.type) {
117 | case ActionKind.ADD_FILE:
118 | if (
119 | action.payload.target === ENTRY_POINT_JSX ||
120 | state.fileList.includes(action.payload.target)
121 | ) {
122 | return state;
123 | }
124 | return {
125 | ...state,
126 | fileList: [...state.fileList, action.payload.target],
127 | vfs: {
128 | ...state.vfs,
129 | [action.payload.target]: action.payload.content,
130 | },
131 | };
132 |
133 | case ActionKind.DELETE_FILE:
134 | if (action.payload.target === ENTRY_POINT_JSX) {
135 | return state;
136 | }
137 | const deleteList = [...state.fileList].filter(
138 | (f) => f !== action.payload.target
139 | );
140 | const deleteVfs = { ...state.vfs };
141 | delete deleteVfs[action.payload.target];
142 | return {
143 | ...state,
144 | fileList: deleteList,
145 | vfs: deleteVfs,
146 | };
147 |
148 | case ActionKind.EDIT_FILE_CONTENT:
149 | if (state.vfs[action.payload.target] === undefined) {
150 | return state;
151 | }
152 | const editContentVfs = { ...state.vfs };
153 | editContentVfs[action.payload.target] = action.payload.content;
154 | return {
155 | ...state,
156 | fileList: [...state.fileList],
157 | vfs: editContentVfs,
158 | };
159 |
160 | case ActionKind.EDIT_FILE_NAME:
161 | if (
162 | action.payload.target === ENTRY_POINT_JSX ||
163 | !state.fileList.includes(action.payload.target) ||
164 | typeof state.vfs[action.payload.target] !== "string"
165 | ) {
166 | return state;
167 | }
168 |
169 | const indexOfEditedFile = state.fileList.indexOf(action.payload.target);
170 | const editedNameFileList = [...state.fileList];
171 | const editedFileContent = state.vfs[action.payload.target];
172 | const editedNameVfs = { ...state.vfs };
173 | editedNameFileList[indexOfEditedFile] = action.payload.content;
174 | delete editedNameVfs[action.payload.target];
175 | editedNameVfs[action.payload.content] = editedFileContent;
176 | return {
177 | ...state,
178 | fileList: editedNameFileList,
179 | vfs: editedNameVfs,
180 | };
181 |
182 | case ActionKind.RESET_VFS:
183 | return defaultState;
184 |
185 | default:
186 | throw new Error();
187 | }
188 | }
189 |
190 | export default function useVFS(vfsFromUrl: VFS | null) {
191 | const [{ vfs, fileList }, dispatch] = useReducer(reducer, vfsFromUrl, init);
192 |
193 | const addFile = useCallback((payload: ActionPayload) => {
194 | dispatch({
195 | type: ActionKind.ADD_FILE,
196 | payload,
197 | });
198 | }, []);
199 |
200 | const deleteFile = useCallback((payload: ActionPayload) => {
201 | dispatch({
202 | type: ActionKind.DELETE_FILE,
203 | payload,
204 | });
205 | }, []);
206 |
207 | const editFileContent = useCallback((payload: ActionPayload) => {
208 | dispatch({
209 | type: ActionKind.EDIT_FILE_CONTENT,
210 | payload,
211 | });
212 | }, []);
213 |
214 | const editFileName = useCallback((payload: ActionPayload) => {
215 | dispatch({
216 | type: ActionKind.EDIT_FILE_NAME,
217 | payload,
218 | });
219 | }, []);
220 |
221 | const resetVFS = useCallback(() => {
222 | dispatch({
223 | type: ActionKind.RESET_VFS,
224 | payload: generatePayload(""),
225 | });
226 | }, []);
227 |
228 | return {
229 | addFile,
230 | deleteFile,
231 | editFileContent,
232 | editFileName,
233 | fileList,
234 | resetVFS,
235 | vfs,
236 | };
237 | }
238 |
--------------------------------------------------------------------------------
/src/hooks/playground/useWindowSize.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 |
3 | interface Size {
4 | width: number | undefined;
5 | height: number | undefined;
6 | }
7 |
8 | const useWindowSize = (): Size => {
9 | const [windowSize, setWindowSize] = useState({
10 | width: undefined,
11 | height: undefined,
12 | });
13 |
14 | useEffect(() => {
15 | function handleResize() {
16 | setWindowSize({
17 | width: window.innerWidth,
18 | height: window.innerHeight,
19 | });
20 | }
21 | window.addEventListener("resize", handleResize);
22 | handleResize();
23 |
24 | return () => window.removeEventListener("resize", handleResize);
25 | }, []);
26 |
27 | return windowSize;
28 | };
29 |
30 | export default useWindowSize;
31 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import url("https://fonts.googleapis.com/css2?family=Ubuntu+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Ubuntu:wght@400;700&display=swap");
2 |
3 | *,
4 | *::after,
5 | *::before {
6 | box-sizing: border-box;
7 | }
8 |
9 | *:focus {
10 | outline: none;
11 | }
12 |
13 | html {
14 | margin: 0;
15 | padding: 0;
16 | font-size: 14px;
17 | }
18 |
19 | body {
20 | margin: 0;
21 | padding: 0;
22 | display: flex;
23 | font-family: "Ubuntu", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
24 | "Droid Sans", "Helvetica Neue", sans-serif;
25 | -webkit-font-smoothing: antialiased;
26 | -moz-osx-font-smoothing: grayscale;
27 | }
28 |
29 | code {
30 | font-family: "Ubuntu Mono", "Courier New", monospace;
31 | }
32 |
33 | button {
34 | font-size: 1rem;
35 | font-family: "Ubuntu", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
36 | "Droid Sans", "Helvetica Neue", sans-serif;
37 | }
38 |
39 | /* ===== Scrollbar CSS ===== */
40 | /* Firefox */
41 | * {
42 | scrollbar-width: auto;
43 | scrollbar-color: #1b2426 #2e4b52;
44 | }
45 |
46 | /* Chrome, Edge, and Safari */
47 | *::-webkit-scrollbar {
48 | width: 8px;
49 | }
50 |
51 | *::-webkit-scrollbar-track {
52 | background: #1b2426;
53 | }
54 |
55 | *::-webkit-scrollbar-thumb {
56 | background-color: #2e4b52;
57 | border-radius: 0px;
58 | border: 0px solid #2e4b52;
59 | }
60 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import "@/index.css";
4 | import App from "@/App";
5 | import { URLStateProvider } from "@/contexts/URLStateContext";
6 |
7 | const root = document.getElementById("root");
8 |
9 | if (!root) throw new Error("root not found");
10 |
11 | ReactDOM.createRoot(root).render(
12 |
13 |
14 |
15 |
16 |
17 | );
18 |
--------------------------------------------------------------------------------
/src/mappers/URLStateMapper.ts:
--------------------------------------------------------------------------------
1 | import { URLStateEntity } from "@/entities";
2 |
3 | export function repositoryToEntity(
4 | urlString: string | null,
5 | parsed: { ts: boolean; vfs: Record } | null,
6 | ): URLStateEntity {
7 | return new URLStateEntity(urlString, parsed);
8 | }
9 |
10 |
--------------------------------------------------------------------------------
/src/repositories/ClipboardRepository.ts:
--------------------------------------------------------------------------------
1 | export type ClipboardRepository = {
2 | copyToClipboard: (text: string) => Promise;
3 | }
--------------------------------------------------------------------------------
/src/repositories/URLStorageRepository.ts:
--------------------------------------------------------------------------------
1 | import { type URLStateEntity } from "@/entities";
2 |
3 | export type URLStorageRepository = {
4 | getURLCurrentState: () => URLStateEntity;
5 | updateURL:(params: {
6 | ts: boolean;
7 | vfs: Record;
8 | }) => void;
9 | };
10 |
--------------------------------------------------------------------------------
/src/repositories/impl/ClipboardRepositoryImpl.ts:
--------------------------------------------------------------------------------
1 | import { type ClipboardRepository } from "@/repositories";
2 |
3 | export class ClipboardRepositoryImpl implements ClipboardRepository {
4 | public async copyToClipboard(text: string): Promise {
5 | // @ts-ignore
6 | if (document.execCommand) {
7 | copyLegacy(text);
8 | return new Promise((resolve) => {
9 | resolve();
10 | });
11 | } else {
12 | return await navigator.clipboard.writeText(text);
13 | }
14 |
15 | function copyLegacy(text: string) {
16 | const textArea = document.createElement("textarea");
17 | textArea.value = text;
18 | textArea.style.top = "0";
19 | textArea.style.left = "0";
20 | textArea.style.position = "fixed";
21 | textArea.style.width = "0";
22 | textArea.style.height = "0";
23 | document.body.appendChild(textArea);
24 | textArea.focus();
25 | textArea.select();
26 | document.execCommand("copy");
27 | document.body.removeChild(textArea);
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/repositories/impl/URLStorageRepositoryImpl.ts:
--------------------------------------------------------------------------------
1 | import { type URLStateEntity } from "@/entities";
2 | import { type URLStorageRepository } from "@/repositories";
3 | import {
4 | compressToEncodedURIComponent as compress,
5 | decompressFromEncodedURIComponent as decompress,
6 | } from "lz-string";
7 | import { repositoryToEntity as toURLEntity } from "@/mappers/URLStateMapper";
8 |
9 | export class URLStorageRepositoryImpl implements URLStorageRepository {
10 | public getURLCurrentState(): URLStateEntity {
11 | const url = new URL(location.href);
12 | const { hash } = url;
13 | const urlStateStringified = decompress(hash.slice(1));
14 |
15 | try {
16 | const urlStateParsed = JSON.parse(urlStateStringified);
17 | return toURLEntity(location.href, urlStateParsed);
18 | } catch (e) {
19 | console.error(e);
20 | return toURLEntity(location.href, null);
21 | }
22 | }
23 |
24 | public updateURL(params: Record): void {
25 | const url = new URL(location.href);
26 | url.hash = compress(JSON.stringify(params));
27 | history.replaceState({}, "", url.toString());
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/repositories/impl/index.ts:
--------------------------------------------------------------------------------
1 | export { ClipboardRepositoryImpl } from './ClipboardRepositoryImpl'
2 | export { URLStorageRepositoryImpl } from './URLStorageRepositoryImpl'
--------------------------------------------------------------------------------
/src/repositories/index.ts:
--------------------------------------------------------------------------------
1 | export { type URLStorageRepository } from './URLStorageRepository'
2 | export { type ClipboardRepository } from './ClipboardRepository'
3 | export { ClipboardRepositoryImpl, URLStorageRepositoryImpl } from "./impl"
--------------------------------------------------------------------------------
/src/tools/browserDOM-tools.ts:
--------------------------------------------------------------------------------
1 | export function runWhenBrowserIsIdle(callback: () => void) {
2 | if ('requestIdleCallback' in window) {
3 | window.requestIdleCallback(callback);
4 | }
5 | else {
6 | setTimeout(callback, 1);
7 | }
8 | }
--------------------------------------------------------------------------------
/src/tools/clipboard-tools.ts:
--------------------------------------------------------------------------------
1 | function copyLegacy(str: string) {
2 | const textArea = document.createElement("textarea");
3 | textArea.value = str;
4 | textArea.style.top = "0";
5 | textArea.style.left = "0";
6 | textArea.style.position = "fixed";
7 | textArea.style.width = "0";
8 | textArea.style.height = "0";
9 | document.body.appendChild(textArea);
10 | textArea.focus();
11 | textArea.select();
12 | document.execCommand("copy");
13 | document.body.removeChild(textArea);
14 | }
15 |
16 | export async function copyToClipboard(str: string): Promise {
17 | // @ts-ignore
18 | if (document.execCommand) {
19 | copyLegacy(str);
20 | return new Promise((resolve) => {
21 | resolve("legacy");
22 | });
23 | } else {
24 | await navigator.clipboard.writeText(str);
25 | return new Promise((resolve) => {
26 | resolve("clipboard API");
27 | });
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/tools/codemirror-tools.ts:
--------------------------------------------------------------------------------
1 | import { colors } from "@/tools/style-tools";
2 | import { createTheme } from "@uiw/codemirror-themes";
3 | import { tags as t } from "@lezer/highlight";
4 |
5 | export const vsCodish = createTheme({
6 | theme: "dark",
7 | settings: {
8 | background: "#1e1e1e",
9 | foreground: "#9cdcfe",
10 | caret: "#c6c6c6",
11 | selection: "#6199ff2f",
12 | selectionMatch: "#72a1ff59",
13 | lineHighlight: "#ffffff0f",
14 | gutterBackground: "#1e1e1e",
15 | gutterForeground: "#838383",
16 | gutterActiveForeground: "#fff",
17 | fontFamily:
18 | 'Menlo, Monaco, Consolas, "Andale Mono", "Ubuntu Mono", "Courier New", monospace',
19 | },
20 | styles: [
21 | {
22 | tag: [
23 | t.keyword,
24 | t.operatorKeyword,
25 | t.modifier,
26 | t.color,
27 | t.constant(t.name),
28 | t.standard(t.name),
29 | t.standard(t.tagName),
30 | t.special(t.brace),
31 | t.atom,
32 | t.bool,
33 | t.special(t.variableName),
34 | ],
35 | color: "#569cd6",
36 | },
37 | {
38 | tag: [t.controlKeyword, t.moduleKeyword],
39 | color: "#c586c0",
40 | },
41 | {
42 | tag: [
43 | t.name,
44 | t.deleted,
45 | t.character,
46 | t.macroName,
47 | t.propertyName,
48 | t.variableName,
49 | t.labelName,
50 | t.definition(t.name),
51 | ],
52 | color: "#9cdcfe",
53 | },
54 | {
55 | tag: t.heading,
56 | fontWeight: "bold",
57 | color: "#9cdcfe",
58 | },
59 | {
60 | tag: [
61 | t.typeName,
62 | t.className,
63 | t.tagName,
64 | t.number,
65 | t.changed,
66 | t.annotation,
67 | t.self,
68 | t.namespace,
69 | ],
70 | color: "#4ec9b0",
71 | },
72 | {
73 | tag: [t.function(t.variableName), t.function(t.propertyName)],
74 | color: "#dcdcaa",
75 | },
76 | {
77 | tag: [t.number],
78 | color: "#b5cea8",
79 | },
80 | {
81 | tag: [t.operator, t.punctuation, t.separator, t.url, t.escape, t.regexp],
82 | color: "#d4d4d4",
83 | },
84 | {
85 | tag: [t.regexp],
86 | color: "#d16969",
87 | },
88 | {
89 | tag: [t.special(t.string), t.processingInstruction, t.string, t.inserted],
90 | color: "#ce9178",
91 | },
92 | {
93 | tag: [t.angleBracket],
94 | color: "#808080",
95 | },
96 | {
97 | tag: t.strong,
98 | fontWeight: "bold",
99 | },
100 | {
101 | tag: t.emphasis,
102 | fontStyle: "italic",
103 | },
104 | {
105 | tag: t.strikethrough,
106 | textDecoration: "line-through",
107 | },
108 | {
109 | tag: [t.meta, t.comment],
110 | color: "#6a9955",
111 | },
112 | {
113 | tag: t.link,
114 | color: "#6a9955",
115 | textDecoration: "underline",
116 | },
117 | {
118 | tag: t.invalid,
119 | color: "#ff0000",
120 | },
121 | ],
122 | });
123 |
--------------------------------------------------------------------------------
/src/tools/console-tools.ts:
--------------------------------------------------------------------------------
1 | import { colors } from "@/tools/style-tools";
2 | import { Styles } from "console-feed/lib/definitions/Styles";
3 |
4 | export const consoleStyles: Styles = {
5 | BASE_FONT_FAMILY: "'Ubuntu Mono', 'Courier New', monospace;",
6 | LOG_BACKGROUND: colors.$bg,
7 | LOG_BORDER: colors.$silver300,
8 | };
9 |
--------------------------------------------------------------------------------
/src/tools/context-tools.tsx:
--------------------------------------------------------------------------------
1 | // https://github.com/jherr/fast-react-context/blob/main/fast-context-generic/src/createFastContext.tsx
2 | import React, {
3 | useRef,
4 | createContext,
5 | useContext,
6 | useCallback,
7 | useSyncExternalStore,
8 | type FC,
9 | type PropsWithChildren,
10 | } from "react";
11 |
12 | export type Store = {
13 | get: () => Value;
14 | set: (value: Partial) => void;
15 | subscribe: (callback: () => void) => () => void;
16 | }
17 |
18 | export function useCreateStore(initialValue: Value): Store {
19 | const store = useRef(initialValue);
20 |
21 | const get = useCallback(() => store.current, []);
22 |
23 | const subscribers = useRef(new Set<() => void>());
24 |
25 | const set = useCallback((value: Partial) => {
26 | store.current = { ...store.current, ...value };
27 | subscribers.current.forEach((callback) => callback());
28 | }, []);
29 |
30 | const subscribe = useCallback((callback: () => void) => {
31 | subscribers.current.add(callback);
32 | return () => subscribers.current.delete(callback);
33 | }, []);
34 |
35 | return {
36 | get,
37 | set,
38 | subscribe,
39 | };
40 | }
41 |
42 | export function createFastContext(initialState: Store) {
43 | function useStoreData(): {
44 | get: () => Store;
45 | set: (value: Partial) => void;
46 | subscribe: (callback: () => void) => () => void;
47 | } {
48 | const store = useRef(initialState);
49 |
50 | const get = useCallback(() => store.current, []);
51 |
52 | const subscribers = useRef(new Set<() => void>());
53 |
54 | const set = useCallback((value: Partial) => {
55 | store.current = { ...store.current, ...value };
56 | subscribers.current.forEach((callback) => callback());
57 | }, []);
58 |
59 | const subscribe = useCallback((callback: () => void) => {
60 | subscribers.current.add(callback);
61 | return () => subscribers.current.delete(callback);
62 | }, []);
63 |
64 | return {
65 | get,
66 | set,
67 | subscribe,
68 | };
69 | }
70 |
71 | type UseStoreDataReturnType = ReturnType;
72 |
73 | const StoreContext = createContext(null);
74 |
75 | const Provider: FC = ({ children }) => {
76 | const value = useStoreData();
77 | return (
78 | {children}
79 | );
80 | };
81 |
82 | function useStore(
83 | selector: (store: Store) => SelectorOutput
84 | ): [SelectorOutput, (value: Partial) => void] {
85 | const store = useContext(StoreContext);
86 | if (!store) {
87 | throw new Error("Store not found");
88 | }
89 |
90 | const state = useSyncExternalStore(
91 | store.subscribe,
92 | () => selector(store.get()),
93 | () => selector(initialState)
94 | );
95 |
96 | return [state, store.set];
97 | }
98 |
99 | return [Provider, useStore] as const;
100 | }
101 | // https://github.com/niksumeiko/iban-validator-react-tdd-kata/blob/main/src/common/context/ContextProvider.tsx
102 | export function composeProviders(
103 | wrappers: FC[]
104 | ): FC {
105 | return wrappers.reduce>(
106 | (Acc, Provider): FC => {
107 | return ({ children }) => (
108 |
109 | {children}
110 |
111 | );
112 | },
113 | ({ children }) => <>{children}>
114 | );
115 | }
116 |
--------------------------------------------------------------------------------
/src/tools/editor-tools.ts:
--------------------------------------------------------------------------------
1 | import { setupTypeAcquisition, ATABootstrapConfig } from "@typescript/ata";
2 | import ts from "typescript";
3 | import { ENTRY_POINT_JSX } from "@/hooks/playground/useVFS";
4 | import {
5 | acceptedFileTypes,
6 | type AcceptedFileType,
7 | } from "@/tools/esbuild-tools";
8 |
9 | const componentCount = countGen();
10 |
11 | export function generateNewTabName(tabs: string[]): string {
12 | let tempLength = componentCount.next().value;
13 | let tempName = `Component${tempLength}.jsx`;
14 |
15 | while (tabs.includes(tempName)) {
16 | tempLength = componentCount.next().value;
17 | tempName = `Component${tempLength}.jsx`;
18 | }
19 |
20 | return tempName;
21 | }
22 |
23 | export function generatePayload(target: string, content?: string) {
24 | return {
25 | target,
26 | content: content ?? "",
27 | };
28 | }
29 |
30 | export function validateTabName(
31 | tabName: string,
32 | prevTabName: string,
33 | tabNames: Array,
34 | ): Array {
35 | let errors = [];
36 | if (!tabName.length) {
37 | errors.push("You have to chose a name!");
38 | }
39 |
40 | if (
41 | tabName === ENTRY_POINT_JSX ||
42 | (tabName !== prevTabName && tabNames.includes(tabName))
43 | ) {
44 | errors.push(
45 | `A file named ${tabName} already exists. Please be creative, find another one.`,
46 | );
47 | }
48 |
49 | if (!/^[A-Za-z0-9.]*$/.test(tabName)) {
50 | errors.push("You can only use letters and digits in the file name.");
51 | }
52 |
53 | const format = tabName.split(".").at(-1);
54 |
55 | if (!format || !acceptedFileTypes.includes(format as AcceptedFileType)) {
56 | errors.push(
57 | "Please chose one of the following file formats: js, jsx or css",
58 | );
59 | }
60 |
61 | return errors;
62 | }
63 |
64 | export function* countGen(initialCount: number = -1): Generator {
65 | let count = initialCount;
66 | while (true) {
67 | yield ++count;
68 | }
69 | }
70 |
71 | const delegateListener = createDelegate();
72 | // https://github.com/vaakian/monaco-ts
73 | const ata = setupTypeAcquisition({
74 | projectName: "monaco-ts",
75 | typescript: ts,
76 | logger: console,
77 | fetcher(input, init) {
78 | // console.log('fetching =>', input, init);
79 | return fetch(input, init);
80 | },
81 | delegate: {
82 | receivedFile: (code, path) => {
83 | delegateListener.receivedFile.forEach((fn) => fn(code, path));
84 | },
85 | progress: (downloaded, total) => {
86 | delegateListener.progress.forEach((fn) => fn(downloaded, total));
87 | },
88 | started: () => {
89 | delegateListener.started.forEach((fn) => fn());
90 | },
91 | finished: (_f) => {
92 | delegateListener.finished.forEach((fn) => fn(_f));
93 | },
94 | },
95 | });
96 |
97 | type DelegateListener = Required<{
98 | [k in keyof ATABootstrapConfig["delegate"]]: Set<
99 | NonNullable
100 | >;
101 | }>;
102 |
103 | function createDelegate() {
104 | const delegate: DelegateListener = {
105 | receivedFile: new Set(),
106 | progress: new Set(),
107 | errorMessage: new Set(),
108 | finished: new Set(),
109 | started: new Set(),
110 | };
111 |
112 | return delegate;
113 | }
114 |
115 | type InferSet = T extends Set ? U : never;
116 |
117 | export function createATA() {
118 | const acquireType = (code: string) => ata(code);
119 | const addListener = (
120 | event: T,
121 | handler: InferSet,
122 | ) => {
123 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
124 | // @ts-ignore
125 | delegateListener[event].add(handler);
126 | };
127 |
128 | const removeListener = (
129 | event: T,
130 | handler: InferSet,
131 | ) => {
132 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
133 | // @ts-ignore
134 | delegateListener[event].delete(handler);
135 | };
136 | return {
137 | acquireType,
138 | addListener,
139 | removeListener,
140 | dispose: () => {
141 | for (const key in delegateListener) {
142 | delegateListener[key as keyof DelegateListener].clear();
143 | }
144 | },
145 | };
146 | }
147 |
--------------------------------------------------------------------------------
/src/tools/esbuild-tools.ts:
--------------------------------------------------------------------------------
1 | export interface BundleError {
2 | errors: Array<{
3 | location: {
4 | column: number;
5 | file: string;
6 | length: number;
7 | line: number;
8 | lineText: string;
9 | };
10 | text: string;
11 | }>;
12 | warnings: Array;
13 | }
14 |
15 | export const libVersionRegex = new RegExp(/@(\d+\.)?(\d+\.)?(\*|\d+)/);
16 |
17 | export type AcceptedFileType = "js" | "jsx" | "css";
18 | export const acceptedFileTypes: AcceptedFileType[] = ["js", "jsx", "css"];
19 |
20 | export function getVersion(
21 | urlImport: string,
22 | ): { lib: string; version: string } | null {
23 | if (!libVersionRegex.test(urlImport)) {
24 | return null;
25 | }
26 |
27 | const [lib, version] = urlImport.split("/")[0].split("@");
28 | return {
29 | lib,
30 | version,
31 | };
32 | }
33 |
34 | export function createErrorString(err: BundleError): string {
35 | const error = err?.errors?.[0];
36 | if (!error || !error.text || !error.location) {
37 | return "Impossible to reproduce, please check your navigator's console.";
38 | }
39 |
40 | const {
41 | text,
42 | location: { column, file, length, line, lineText },
43 | } = error;
44 | let underline = "";
45 | let space = "";
46 | let gutter = "";
47 |
48 | for (let i = 0; i < column; i++) {
49 | space = space + " ";
50 | }
51 |
52 | for (let i = 0; i < length; i++) {
53 | underline = underline + "^";
54 | }
55 |
56 | for (let i = 0; i < line.toString().length; i++) {
57 | gutter = gutter + " ";
58 | }
59 | return `
60 | ${text}
61 | In: ${file}:
62 | ${line} | ${lineText}
63 | ${gutter} ${space}${underline}
64 | `.trim();
65 | }
66 |
67 | /**
68 | * possible regex for all npm packages,
69 | * need to add a part for - and / in the name
70 | * e.g. @babel/cli or evento-react
71 | /@?[a-z]@[~^]?([\dvx*]+(?:[-.](?:[\dx*]+|alpha|beta))*)/g
72 | */
73 |
--------------------------------------------------------------------------------
/src/tools/exports-tools.ts:
--------------------------------------------------------------------------------
1 | import { VFS } from "@/hooks/playground/useVFS";
2 | import { ENTRY_POINT_JSX } from "@/hooks/playground/useVFS";
3 | import { compressToBase64 } from "lz-string";
4 | import { CDN, make_CDN_URL } from "@/hooks/playground/useEsbuild";
5 | import dedent from "dedent";
6 |
7 | interface CodeSanboxFile {
8 | content: string;
9 | isBinary: boolean;
10 | }
11 |
12 | interface CodeSandboxFilesTree {
13 | files: {
14 | [key: string]: CodeSanboxFile;
15 | };
16 | }
17 |
18 | interface RawImport {
19 | bytes: number;
20 | imports: Array<{
21 | kind: string;
22 | path: string;
23 | }>;
24 | }
25 |
26 | export interface RawImports {
27 | [key: string]: RawImport;
28 | }
29 |
30 | const PIN_REGEX = /\?pin=v\d+/g;
31 | const VERSION_REGEX = /v\d+\//;
32 | const STARTS_WITH_VERSION_REGEX = /^v\d+\//;
33 |
34 | const htmlFileCodeSandBox = dedent`
35 |
36 |
37 |
38 |
39 |
40 | React Playground
41 |
42 |
43 |
44 |
45 |
46 |
47 | `;
48 |
49 | const htmlFileStackBlitz = dedent`
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | `;
62 |
63 | const viteConfig = dedent`
64 | import { defineConfig } from 'vite'
65 | import react from '@vitejs/plugin-react'
66 |
67 | // https://vitejs.dev/config/
68 | export default defineConfig({
69 | plugins: [react()]
70 | })
71 | `;
72 |
73 | function getCodeSandboxFilesTree(
74 | fileList: string[],
75 | vfs: VFS,
76 | ): { [key: string]: CodeSanboxFile } {
77 | return fileList.reduce(
78 | (acc: { [key: string]: CodeSanboxFile }, val: string) => {
79 | acc[`src/${val}`] = {
80 | content: vfs[val],
81 | isBinary: false,
82 | };
83 | return acc;
84 | },
85 | {},
86 | );
87 | }
88 |
89 | async function getCodeSandboxParameters(
90 | fileList: string[],
91 | rawImports: RawImports,
92 | vfs: VFS,
93 | ): Promise {
94 | const packageJSON = await getVitePackageJSON(rawImports);
95 | const parameters: CodeSandboxFilesTree = {
96 | files: {
97 | ...getCodeSandboxFilesTree(fileList, vfs),
98 | "index.html": {
99 | content: htmlFileCodeSandBox,
100 | isBinary: false,
101 | },
102 | "package.json": {
103 | content: packageJSON,
104 | isBinary: false,
105 | },
106 | "vite.config.js": {
107 | content: viteConfig,
108 | isBinary: false,
109 | },
110 | },
111 | };
112 |
113 | return compressToBase64(JSON.stringify(parameters))
114 | .replace(/\+/g, "-") // Convert '+' to '-'
115 | .replace(/\//g, "_") // Convert '/' to '_'
116 | .replace(/=+$/, ""); // Remove ending '='
117 | }
118 |
119 | async function getDependencies(
120 | rawImports: RawImports,
121 | ): Promise<{ [key: string]: string }> {
122 | const rawImportsFromEMSSH: string[] = Object.keys(rawImports).filter(
123 | (rawImport) =>
124 | rawImport.startsWith("b:") &&
125 | rawImport.includes("@") &&
126 | !PIN_REGEX.test(rawImport),
127 | );
128 |
129 | const dependencies = rawImportsFromEMSSH.reduce(
130 | (dependenciesObj: { [key: string]: string }, rawImport: string) => {
131 | let withoutCDN = rawImport.replace(`b:${CDN}/`, "");
132 |
133 | if (withoutCDN.startsWith("stable")) {
134 | withoutCDN = withoutCDN.replace("stable/", "");
135 | }
136 |
137 | if (STARTS_WITH_VERSION_REGEX.test(withoutCDN)) {
138 | withoutCDN = withoutCDN.replace(VERSION_REGEX, "");
139 | }
140 |
141 | const isPrivatePkg = withoutCDN.startsWith("@");
142 | const splittedAtAddressSign = withoutCDN.split("@");
143 | const pkgName = isPrivatePkg
144 | ? `@${splittedAtAddressSign[1]}`
145 | : splittedAtAddressSign[0];
146 | const version = isPrivatePkg
147 | ? splittedAtAddressSign[2].split("/")[0]
148 | : splittedAtAddressSign[1].split("/")[0];
149 |
150 | if (!dependenciesObj[pkgName]) {
151 | dependenciesObj[pkgName] = version;
152 | }
153 |
154 | return dependenciesObj;
155 | },
156 | {},
157 | );
158 |
159 | return dependencies;
160 | }
161 |
162 | async function getCRAPackageJSON(rawImports: RawImports): Promise {
163 | const dependencies = await getDependencies(rawImports);
164 |
165 | const packageJSON = {
166 | name: "CRA-react-starter",
167 | private: true,
168 | version: "0.0.0",
169 | dependencies: dependencies,
170 | scripts: {
171 | start: "react-scripts start",
172 | build: "react-scripts build",
173 | test: "react-scripts test --env=jsdom",
174 | eject: "react-scripts eject",
175 | },
176 | devDependencies: {
177 | "react-scripts": "latest",
178 | },
179 | browserslist: {
180 | development: [
181 | "last 1 chrome version",
182 | "last 1 firefox version",
183 | "last 1 safari version",
184 | ],
185 | },
186 | };
187 | return JSON.stringify(packageJSON, null, 4);
188 | }
189 |
190 | async function getVitePackageJSON(rawImports: RawImports): Promise {
191 | const dependencies = await getDependencies(rawImports);
192 |
193 | const packageJSON = {
194 | name: "vite-react-starter",
195 | private: true,
196 | version: "0.0.0",
197 | type: "module",
198 | scripts: {
199 | dev: "vite",
200 | build: "vite build",
201 | preview: "vite preview",
202 | },
203 | dependencies: dependencies,
204 | devDependencies: {
205 | "@vitejs/plugin-react": "^1.3.2",
206 | vite: "^2.9.12",
207 | },
208 | };
209 | return JSON.stringify(packageJSON, null, 4);
210 | }
211 |
212 | export function exportToCodeSandbox(
213 | fileList: string[],
214 | rawImports: RawImports,
215 | vfs: VFS,
216 | ): void {
217 | getCodeSandboxParameters(fileList, rawImports, vfs).then((parameters) => {
218 | const url = `https://codesandbox.io/api/v1/sandboxes/define?parameters=${parameters}`;
219 | exportFromURL(url);
220 | });
221 | }
222 |
223 | function exportFromURL(url: string, downloadName?: string) {
224 | const a = document.createElement("a");
225 | a.setAttribute("href", url);
226 | a.setAttribute("target", "_blank");
227 | a.setAttribute("rel", "noopener");
228 |
229 | if (downloadName) a.setAttribute("download", downloadName);
230 |
231 | document.body.appendChild(a);
232 | a.click();
233 | a.remove();
234 | }
235 |
236 | async function getStackblitzProjectPayload(
237 | fileList: string[],
238 | rawImports: RawImports,
239 | vfs: VFS,
240 | ) {
241 | return {
242 | files: { ...vfs, ["index.html"]: htmlFileStackBlitz },
243 | title: "React Playground",
244 | description: "Your React Playground with CRA on Stackblitz",
245 | template: "create-react-app",
246 | dependencies: await getDependencies(rawImports),
247 | };
248 | }
249 |
250 | export async function exportToStackblitz(
251 | fileList: string[],
252 | rawImports: RawImports,
253 | vfs: VFS,
254 | ) {
255 | const { default: StackblitzSDK } = await import("@stackblitz/sdk");
256 | const projectPayload = await getStackblitzProjectPayload(
257 | fileList,
258 | rawImports,
259 | vfs,
260 | );
261 | // @ts-ignore, pkg imported dinamically
262 | StackblitzSDK.openProject(projectPayload);
263 | }
264 |
265 | export async function exportToZip(
266 | fileList: string[],
267 | rawImports: RawImports,
268 | vfs: VFS,
269 | ) {
270 | const { default: JSZip } = await import("jszip");
271 | const zip = new JSZip();
272 |
273 | const publicFolder = zip.folder("public");
274 | const srcFolder = zip.folder("src");
275 |
276 | if (!publicFolder || !srcFolder) return;
277 |
278 | publicFolder.file("index.html", htmlFileStackBlitz);
279 |
280 | fileList.forEach((fileName) => srcFolder.file(fileName, vfs[fileName]));
281 |
282 | const CRAPackageJSON = await getCRAPackageJSON(rawImports);
283 | zip.file("package.json", CRAPackageJSON);
284 |
285 | const zipBlob = await zip.generateAsync({ type: "blob" });
286 | const downloadURL = URL.createObjectURL(zipBlob);
287 | exportFromURL(downloadURL, "react-playground-project");
288 | }
289 |
--------------------------------------------------------------------------------
/src/tools/iframe-tools.ts:
--------------------------------------------------------------------------------
1 | import { colors } from "@/tools/style-tools";
2 |
3 | export const sandboxAttributes =
4 | "allow-popups-to-escape-sandbox allow-scripts allow-popups allow-forms allow-pointer-lock allow-top-navigation allow-modals allow-same-origin";
5 |
6 | export const srcDoc = /*html*/ `
7 |
8 |
9 |
12 |
13 |
14 |
15 |
79 |
80 |
81 | `.trim();
82 |
83 | export const initialLoader = `
84 | const style = document.createElement('style')
85 |
86 | style.textContent = \`
87 | #root {
88 | height: 90vh;
89 | width: 95vw;
90 | display: grid;
91 | place-content: center;
92 | }
93 |
94 | .lds-ellipsis {
95 | place-self: center;
96 | display: inline-block;
97 | position: relative;
98 | width: 80px;
99 | height: 80px;
100 | }
101 | .lds-ellipsis div {
102 | position: absolute;
103 | top: 33px;
104 | width: 13px;
105 | height: 13px;
106 | border-radius: 50%;
107 | background: ${colors.$react};
108 | animation-timing-function: cubic-bezier(0, 1, 1, 0);
109 | }
110 | .lds-ellipsis div:nth-child(1) {
111 | left: 8px;
112 | animation: lds-ellipsis1 0.6s infinite;
113 | }
114 | .lds-ellipsis div:nth-child(2) {
115 | left: 8px;
116 | animation: lds-ellipsis2 0.6s infinite;
117 | }
118 | .lds-ellipsis div:nth-child(3) {
119 | left: 32px;
120 | animation: lds-ellipsis2 0.6s infinite;
121 | }
122 | .lds-ellipsis div:nth-child(4) {
123 | left: 56px;
124 | animation: lds-ellipsis3 0.6s infinite;
125 | }
126 | @keyframes lds-ellipsis1 {
127 | 0% {
128 | transform: scale(0);
129 | }
130 | 100% {
131 | transform: scale(1);
132 | }
133 | }
134 | @keyframes lds-ellipsis3 {
135 | 0% {
136 | transform: scale(1);
137 | }
138 | 100% {
139 | transform: scale(0);
140 | }
141 | }
142 | @keyframes lds-ellipsis2 {
143 | 0% {
144 | transform: translate(0, 0);
145 | }
146 | 100% {
147 | transform: translate(24px, 0);
148 | }
149 | }
150 | \`
151 |
152 | document.head.appendChild(style)
153 | const root = document.getElementById('root')
154 | root.innerHTML = 'Creating the bundle
'
155 | `;
156 |
157 | export const srcDocWithInternalConsole = /*html*/ `
158 |
159 |
160 |
161 |
162 |
248 |
249 |
250 | `.trim();
251 |
--------------------------------------------------------------------------------
/src/tools/style-tools.ts:
--------------------------------------------------------------------------------
1 | export function makeClassName(classNames: string[]): string {
2 | return classNames.join(" ");
3 | }
4 |
5 | export const colors = {
6 | $bg: "#1e1e1e",
7 | $bgNav: "#2e3d51",
8 | $blue: "#5ba1cf",
9 | $brown: "#ce966f",
10 | $teal: "#5ab0b0",
11 | $scrollbarThumb: "#2e4b52",
12 | $silver100: "#fff",
13 | $silver200: "#b9bdb6",
14 | $silver300: "#8c8e8a",
15 | $orange: "#ffaa3e",
16 | $purple: "#ce61fb",
17 | $purple200: "#a06cb6",
18 | $red: "#e24a4a",
19 | $yellow: "#e6d238",
20 | // file format colors
21 | $css: "#264de4",
22 | $react: "#61dafb",
23 | $javascript: "#f7df1e",
24 | } as const;
25 |
26 | // CE61FB => viola
27 | // FB8161 => coomplementare react
28 |
29 | export const fixedSizes: Record = {
30 | navbarHeight: "50px",
31 | editorTabsContainerHeight: "40px",
32 | };
33 |
34 | export const generalBorderStyle = `1px solid ${colors.$silver300}`;
35 |
36 | export const responsiveBreakpoint = 540;
37 |
38 | export const transitionDuration = {
39 | fast: "100ms",
40 | medium: "300ms",
41 | };
42 |
43 | export const languageToColor: Record = {
44 | css: colors.$css,
45 | js: colors.$javascript,
46 | jsx: colors.$react,
47 | };
48 |
--------------------------------------------------------------------------------
/src/useCases/URLStateUseCases.ts:
--------------------------------------------------------------------------------
1 | import {
2 | URLStateEntity,
3 | type ParsedV2,
4 | } from "@/entities";
5 | import { type URLStorageRepository } from "@/repositories";
6 | import { type ClipboardRepository } from "@/repositories";
7 |
8 | type Repositories = {
9 | clipboardRepository: ClipboardRepository;
10 | urlStorageRepository: URLStorageRepository;
11 | };
12 |
13 | export class URLStateUseCases {
14 | #clipboardRepository: ClipboardRepository;
15 | #urlStorageRepository: URLStorageRepository;
16 |
17 | constructor({ clipboardRepository, urlStorageRepository }: Repositories) {
18 | this.#clipboardRepository = clipboardRepository;
19 | this.#urlStorageRepository = urlStorageRepository;
20 | }
21 |
22 | public getURLState(): URLStateEntity {
23 | return this.#urlStorageRepository.getURLCurrentState();
24 | }
25 |
26 | public extractVFSFromURL(
27 | parsed: URLStateEntity["parsed"] = this.getURLState().parsed
28 | ): Record | null {
29 | if (this.#isVersion2(parsed)) return parsed.vfs;
30 | return parsed;
31 | }
32 |
33 | public updateURL(
34 | vfsState: ParsedV2
35 | ) {
36 | this.#urlStorageRepository.updateURL({
37 | ts: vfsState.ts,
38 | vfs: vfsState.vfs,
39 | });
40 | }
41 |
42 | public copyURLToClipboard(): Promise {
43 | try {
44 | const { urlString } = this.#urlStorageRepository.getURLCurrentState();
45 | if (!urlString) throw new Error("URL is empty");
46 | return this.#clipboardRepository.copyToClipboard(urlString);
47 | } catch (e) {
48 | console.error(e);
49 | return Promise.reject(e);
50 | }
51 | }
52 |
53 | #isVersion2(parsedURL: URLStateEntity["parsed"]): parsedURL is ParsedV2 {
54 | return (
55 | typeof parsedURL === "object" &&
56 | parsedURL !== null &&
57 | "ts" in parsedURL &&
58 | "vfs" in parsedURL &&
59 | typeof parsedURL.ts === "boolean" &&
60 | typeof parsedURL.vfs === "object"
61 | );
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/useCases/VFSStateUseCases.ts:
--------------------------------------------------------------------------------
1 | import { VFSStateEntity } from "@/entities";
2 |
3 | export class VFSStateUseCases {
4 | public updateFileContent({
5 | fileName,
6 | fileContent,
7 | vfsState,
8 | }: {
9 | fileName: keyof VFSStateEntity["vfs"];
10 | fileContent: string;
11 | vfsState: VFSStateEntity;
12 | }): VFSStateEntity {
13 | const { vfs } = vfsState;
14 | if (!vfs[fileName]) throw new Error("File not found");
15 | const updatedVFS = {
16 | ...vfsState,
17 | vfs: { ...vfs, [fileName]: fileContent },
18 | };
19 | return updatedVFS;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/useCases/index.ts:
--------------------------------------------------------------------------------
1 | export { URLStateUseCases } from './URLStateUseCases'
2 | export { VFSStateUseCases } from './VFSStateUseCases'
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/workers/codeFormatter/codeFormatter.worker.ts:
--------------------------------------------------------------------------------
1 | // this is prettier@3.0.3
2 | import * as prettier from "./prettier/standalone";
3 | import babelPlugin from "./prettier/plugins/babel";
4 | import cssPlugin from "./prettier/plugins/postcss";
5 | import estreePlugin from "./prettier/plugins/estree";
6 | import { type AcceptedFileType } from "@/tools/esbuild-tools";
7 |
8 | export type FormatResponseData =
9 | | {
10 | type: "code";
11 | data: string;
12 | error: null;
13 | }
14 | | {
15 | type: "error";
16 | data: string;
17 | error: Error;
18 | };
19 |
20 | export interface FormatRequestData {
21 | type: "code";
22 | data: {
23 | code: string;
24 | lang: AcceptedFileType;
25 | };
26 | }
27 |
28 | const fileTypeToParser: Record = {
29 | js: "babel",
30 | jsx: "babel",
31 | css: "css",
32 | };
33 |
34 | const fileTypeToPlugins: Record = {
35 | js: [babelPlugin, estreePlugin],
36 | jsx: [babelPlugin, estreePlugin],
37 | css: [cssPlugin],
38 | };
39 | self.onmessage = (event: { data: FormatRequestData }) => {
40 | const { type, data } = event.data;
41 | if (type !== "code") {
42 | return;
43 | }
44 |
45 | const { code, lang } = data;
46 |
47 | prettier
48 | // @ts-ignore
49 | .format(code, {
50 | parser: fileTypeToParser[lang],
51 | plugins: fileTypeToPlugins[lang],
52 | })
53 | // @ts-ignore
54 | .then((data) => {
55 | self.postMessage({
56 | type: "code",
57 | data,
58 | error: null,
59 | });
60 | })
61 | // @ts-ignore
62 | .catch((error) => {
63 | self.postMessage({
64 | type: "error",
65 | data: code,
66 | error,
67 | });
68 | });
69 | };
70 |
--------------------------------------------------------------------------------
/src/workers/codeFormatter/prettier/doc.d.ts:
--------------------------------------------------------------------------------
1 | // https://github.com/prettier/prettier/blob/next/src/document/public.js
2 | export namespace builders {
3 | type DocCommand =
4 | | Align
5 | | BreakParent
6 | | Cursor
7 | | Fill
8 | | Group
9 | | IfBreak
10 | | Indent
11 | | IndentIfBreak
12 | | Label
13 | | Line
14 | | LineSuffix
15 | | LineSuffixBoundary
16 | | Trim;
17 | type Doc = string | Doc[] | DocCommand;
18 |
19 | interface Align {
20 | type: "align";
21 | contents: Doc;
22 | n: number | string | { type: "root" };
23 | }
24 |
25 | interface BreakParent {
26 | type: "break-parent";
27 | }
28 |
29 | interface Cursor {
30 | type: "cursor";
31 | placeholder: symbol;
32 | }
33 |
34 | interface Fill {
35 | type: "fill";
36 | parts: Doc[];
37 | }
38 |
39 | interface Group {
40 | type: "group";
41 | id?: symbol;
42 | contents: Doc;
43 | break: boolean;
44 | expandedStates: Doc[];
45 | }
46 |
47 | interface HardlineWithoutBreakParent extends Line {
48 | hard: true;
49 | }
50 |
51 | interface IfBreak {
52 | type: "if-break";
53 | breakContents: Doc;
54 | flatContents: Doc;
55 | }
56 |
57 | interface Indent {
58 | type: "indent";
59 | contents: Doc;
60 | }
61 |
62 | interface IndentIfBreak {
63 | type: "indent-if-break";
64 | }
65 |
66 | interface Label {
67 | type: "label";
68 | label: any;
69 | contents: Doc;
70 | }
71 |
72 | interface Line {
73 | type: "line";
74 | soft?: boolean | undefined;
75 | hard?: boolean | undefined;
76 | literal?: boolean | undefined;
77 | }
78 |
79 | interface LineSuffix {
80 | type: "line-suffix";
81 | contents: Doc;
82 | }
83 |
84 | interface LineSuffixBoundary {
85 | type: "line-suffix-boundary";
86 | }
87 |
88 | interface LiterallineWithoutBreakParent extends Line {
89 | hard: true;
90 | literal: true;
91 | }
92 |
93 | type LiteralLine = [LiterallineWithoutBreakParent, BreakParent];
94 |
95 | interface Softline extends Line {
96 | soft: true;
97 | }
98 |
99 | type Hardline = [HardlineWithoutBreakParent, BreakParent];
100 |
101 | interface Trim {
102 | type: "trim";
103 | }
104 |
105 | interface GroupOptions {
106 | shouldBreak?: boolean | undefined;
107 | id?: symbol | undefined;
108 | }
109 |
110 | function addAlignmentToDoc(doc: Doc, size: number, tabWidth: number): Doc;
111 |
112 | /** @see [align](https://github.com/prettier/prettier/blob/main/commands.md#align) */
113 | function align(widthOrString: Align["n"], doc: Doc): Align;
114 |
115 | /** @see [breakParent](https://github.com/prettier/prettier/blob/main/commands.md#breakparent) */
116 | const breakParent: BreakParent;
117 |
118 | /** @see [conditionalGroup](https://github.com/prettier/prettier/blob/main/commands.md#conditionalgroup) */
119 | function conditionalGroup(alternatives: Doc[], options?: GroupOptions): Group;
120 |
121 | /** @see [dedent](https://github.com/prettier/prettier/blob/main/commands.md#dedent) */
122 | function dedent(doc: Doc): Align;
123 |
124 | /** @see [dedentToRoot](https://github.com/prettier/prettier/blob/main/commands.md#dedenttoroot) */
125 | function dedentToRoot(doc: Doc): Align;
126 |
127 | /** @see [fill](https://github.com/prettier/prettier/blob/main/commands.md#fill) */
128 | function fill(docs: Doc[]): Fill;
129 |
130 | /** @see [group](https://github.com/prettier/prettier/blob/main/commands.md#group) */
131 | function group(doc: Doc, opts?: GroupOptions): Group;
132 |
133 | /** @see [hardline](https://github.com/prettier/prettier/blob/main/commands.md#hardline) */
134 | const hardline: Hardline;
135 |
136 | /** @see [hardlineWithoutBreakParent](https://github.com/prettier/prettier/blob/main/commands.md#hardlinewithoutbreakparent-and-literallinewithoutbreakparent) */
137 | const hardlineWithoutBreakParent: HardlineWithoutBreakParent;
138 |
139 | /** @see [ifBreak](https://github.com/prettier/prettier/blob/main/commands.md#ifbreak) */
140 | function ifBreak(
141 | ifBreak: Doc,
142 | noBreak?: Doc,
143 | options?: { groupId?: symbol | undefined },
144 | ): IfBreak;
145 |
146 | /** @see [indent](https://github.com/prettier/prettier/blob/main/commands.md#indent) */
147 | function indent(doc: Doc): Indent;
148 |
149 | /** @see [indentIfBreak](https://github.com/prettier/prettier/blob/main/commands.md#indentifbreak) */
150 | function indentIfBreak(
151 | doc: Doc,
152 | opts: { groupId: symbol; negate?: boolean | undefined },
153 | ): IndentIfBreak;
154 |
155 | /** @see [join](https://github.com/prettier/prettier/blob/main/commands.md#join) */
156 | function join(sep: Doc, docs: Doc[]): Doc[];
157 |
158 | /** @see [label](https://github.com/prettier/prettier/blob/main/commands.md#label) */
159 | function label(label: any | undefined, contents: Doc): Doc;
160 |
161 | /** @see [line](https://github.com/prettier/prettier/blob/main/commands.md#line) */
162 | const line: Line;
163 |
164 | /** @see [lineSuffix](https://github.com/prettier/prettier/blob/main/commands.md#linesuffix) */
165 | function lineSuffix(suffix: Doc): LineSuffix;
166 |
167 | /** @see [lineSuffixBoundary](https://github.com/prettier/prettier/blob/main/commands.md#linesuffixboundary) */
168 | const lineSuffixBoundary: LineSuffixBoundary;
169 |
170 | /** @see [literalline](https://github.com/prettier/prettier/blob/main/commands.md#literalline) */
171 | const literalline: LiteralLine;
172 |
173 | /** @see [literallineWithoutBreakParent](https://github.com/prettier/prettier/blob/main/commands.md#hardlinewithoutbreakparent-and-literallinewithoutbreakparent) */
174 | const literallineWithoutBreakParent: LiterallineWithoutBreakParent;
175 |
176 | /** @see [markAsRoot](https://github.com/prettier/prettier/blob/main/commands.md#markasroot) */
177 | function markAsRoot(doc: Doc): Align;
178 |
179 | /** @see [softline](https://github.com/prettier/prettier/blob/main/commands.md#softline) */
180 | const softline: Softline;
181 |
182 | /** @see [trim](https://github.com/prettier/prettier/blob/main/commands.md#trim) */
183 | const trim: Trim;
184 |
185 | /** @see [cursor](https://github.com/prettier/prettier/blob/main/commands.md#cursor) */
186 | const cursor: Cursor;
187 | }
188 |
189 | export namespace printer {
190 | function printDocToString(
191 | doc: builders.Doc,
192 | options: Options,
193 | ): {
194 | formatted: string;
195 | cursorNodeStart?: number | undefined;
196 | cursorNodeText?: string | undefined;
197 | };
198 | interface Options {
199 | /**
200 | * Specify the line length that the printer will wrap on.
201 | * @default 80
202 | */
203 | printWidth: number;
204 | /**
205 | * Specify the number of spaces per indentation-level.
206 | * @default 2
207 | */
208 | tabWidth: number;
209 | /**
210 | * Indent lines with tabs instead of spaces
211 | * @default false
212 | */
213 | useTabs?: boolean;
214 | parentParser?: string | undefined;
215 | __embeddedInHtml?: boolean | undefined;
216 | }
217 | }
218 |
219 | export namespace utils {
220 | function willBreak(doc: builders.Doc): boolean;
221 | function traverseDoc(
222 | doc: builders.Doc,
223 | onEnter?: (doc: builders.Doc) => void | boolean,
224 | onExit?: (doc: builders.Doc) => void,
225 | shouldTraverseConditionalGroups?: boolean,
226 | ): void;
227 | function findInDoc(
228 | doc: builders.Doc,
229 | callback: (doc: builders.Doc) => T,
230 | defaultValue: T,
231 | ): T;
232 | function mapDoc(
233 | doc: builders.Doc,
234 | callback: (doc: builders.Doc) => T,
235 | ): T;
236 | function removeLines(doc: builders.Doc): builders.Doc;
237 | function stripTrailingHardline(doc: builders.Doc): builders.Doc;
238 | function replaceEndOfLine(
239 | doc: builders.Doc,
240 | replacement?: builders.Doc,
241 | ): builders.Doc;
242 | function canBreak(doc: builders.Doc): boolean;
243 | }
244 |
--------------------------------------------------------------------------------
/src/workers/codeFormatter/prettier/index.d.ts:
--------------------------------------------------------------------------------
1 | // Copied from `@types/prettier`
2 | // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/5bb07fc4b087cb7ee91084afa6fe750551a7bbb1/types/prettier/index.d.ts
3 |
4 | // Minimum TypeScript Version: 4.2
5 |
6 | // Add `export {}` here to shut off automatic exporting from index.d.ts. There
7 | // are quite a few utility types here that don't need to be shipped with the
8 | // exported module.
9 | export {};
10 |
11 | import { builders, printer, utils } from "./doc";
12 |
13 | export namespace doc {
14 | export { builders, printer, utils };
15 | }
16 |
17 | // This utility is here to handle the case where you have an explicit union
18 | // between string literals and the generic string type. It would normally
19 | // resolve out to just the string type, but this generic LiteralUnion maintains
20 | // the intellisense of the original union.
21 | //
22 | // It comes from this issue: microsoft/TypeScript#29729:
23 | // https://github.com/microsoft/TypeScript/issues/29729#issuecomment-700527227
24 | export type LiteralUnion =
25 | | T
26 | | (Pick & { _?: never | undefined });
27 |
28 | export type AST = any;
29 | export type Doc = doc.builders.Doc;
30 |
31 | // The type of elements that make up the given array T.
32 | type ArrayElement = T extends Array ? E : never;
33 |
34 | // A union of the properties of the given object that are arrays.
35 | type ArrayProperties = {
36 | [K in keyof T]: NonNullable extends readonly any[] ? K : never;
37 | }[keyof T];
38 |
39 | // A union of the properties of the given array T that can be used to index it.
40 | // If the array is a tuple, then that's going to be the explicit indices of the
41 | // array, otherwise it's going to just be number.
42 | type IndexProperties = IsTuple extends true
43 | ? Exclude["length"], T["length"]>
44 | : number;
45 |
46 | // Effectively performing T[P], except that it's telling TypeScript that it's
47 | // safe to do this for tuples, arrays, or objects.
48 | type IndexValue = T extends any[]
49 | ? P extends number
50 | ? T[P]
51 | : never
52 | : P extends keyof T
53 | ? T[P]
54 | : never;
55 |
56 | // Determines if an object T is an array like string[] (in which case this
57 | // evaluates to false) or a tuple like [string] (in which case this evaluates to
58 | // true).
59 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
60 | type IsTuple = T extends []
61 | ? true
62 | : T extends [infer First, ...infer Remain]
63 | ? IsTuple
64 | : false;
65 |
66 | type CallProperties = T extends any[] ? IndexProperties : keyof T;
67 | type IterProperties = T extends any[]
68 | ? IndexProperties
69 | : ArrayProperties;
70 |
71 | type CallCallback = (path: AstPath, index: number, value: any) => U;
72 | type EachCallback = (
73 | path: AstPath>,
74 | index: number,
75 | value: any,
76 | ) => void;
77 | type MapCallback = (
78 | path: AstPath>,
79 | index: number,
80 | value: any,
81 | ) => U;
82 |
83 | // https://github.com/prettier/prettier/blob/next/src/common/ast-path.js
84 | export class AstPath {
85 | constructor(value: T);
86 |
87 | get key(): string | null;
88 | get index(): number | null;
89 | get node(): T;
90 | get parent(): T | null;
91 | get grandparent(): T | null;
92 | get isInArray(): boolean;
93 | get siblings(): T[] | null;
94 | get next(): T | null;
95 | get previous(): T | null;
96 | get isFirst(): boolean;
97 | get isLast(): boolean;
98 | get isRoot(): boolean;
99 | get root(): T;
100 | get ancestors(): T[];
101 |
102 | stack: T[];
103 |
104 | callParent(callback: (path: this) => U, count?: number): U;
105 |
106 | /**
107 | * @deprecated Please use `AstPath#key` or `AstPath#index`
108 | */
109 | getName(): PropertyKey | null;
110 |
111 | /**
112 | * @deprecated Please use `AstPath#node` or `AstPath#siblings`
113 | */
114 | getValue(): T;
115 |
116 | getNode(count?: number): T | null;
117 |
118 | getParentNode(count?: number): T | null;
119 |
120 | match(
121 | ...predicates: Array<
122 | (node: any, name: string | null, number: number | null) => boolean
123 | >
124 | ): boolean;
125 |
126 | // For each of the tree walk functions (call, each, and map) this provides 5
127 | // strict type signatures, along with a fallback at the end if you end up
128 | // calling more than 5 properties deep. This helps a lot with typing because
129 | // for the majority of cases you're calling fewer than 5 properties, so the
130 | // tree walk functions have a clearer understanding of what you're doing.
131 | //
132 | // Note that resolving these types is somewhat complicated, and it wasn't
133 | // even supported until TypeScript 4.2 (before it would just say that the
134 | // type instantiation was excessively deep and possibly infinite).
135 |
136 | call(callback: CallCallback): U;
137 | call>(
138 | callback: CallCallback, U>,
139 | prop1: P1,
140 | ): U;
141 | call>(
142 | callback: CallCallback, P2>, U>,
143 | prop1: P1,
144 | prop2: P2,
145 | ): U;
146 | call<
147 | U,
148 | P1 extends keyof T,
149 | P2 extends CallProperties,
150 | P3 extends CallProperties>,
151 | >(
152 | callback: CallCallback<
153 | IndexValue, P2>, P3>,
154 | U
155 | >,
156 | prop1: P1,
157 | prop2: P2,
158 | prop3: P3,
159 | ): U;
160 | call<
161 | U,
162 | P1 extends keyof T,
163 | P2 extends CallProperties,
164 | P3 extends CallProperties>,
165 | P4 extends CallProperties, P3>>,
166 | >(
167 | callback: CallCallback<
168 | IndexValue, P2>, P3>, P4>,
169 | U
170 | >,
171 | prop1: P1,
172 | prop2: P2,
173 | prop3: P3,
174 | prop4: P4,
175 | ): U;
176 | call(
177 | callback: CallCallback,
178 | prop1: P,
179 | prop2: P,
180 | prop3: P,
181 | prop4: P,
182 | ...props: P[]
183 | ): U;
184 |
185 | each(callback: EachCallback): void;
186 | each>(
187 | callback: EachCallback>,
188 | prop1: P1,
189 | ): void;
190 | each>(
191 | callback: EachCallback, P2>>,
192 | prop1: P1,
193 | prop2: P2,
194 | ): void;
195 | each<
196 | P1 extends keyof T,
197 | P2 extends IterProperties,
198 | P3 extends IterProperties>,
199 | >(
200 | callback: EachCallback, P2>, P3>>,
201 | prop1: P1,
202 | prop2: P2,
203 | prop3: P3,
204 | ): void;
205 | each<
206 | P1 extends keyof T,
207 | P2 extends IterProperties,
208 | P3 extends IterProperties>,
209 | P4 extends IterProperties, P3>>,
210 | >(
211 | callback: EachCallback<
212 | IndexValue, P2>, P3>, P4>
213 | >,
214 | prop1: P1,
215 | prop2: P2,
216 | prop3: P3,
217 | prop4: P4,
218 | ): void;
219 | each(
220 | callback: EachCallback,
221 | prop1: PropertyKey,
222 | prop2: PropertyKey,
223 | prop3: PropertyKey,
224 | prop4: PropertyKey,
225 | ...props: PropertyKey[]
226 | ): void;
227 |
228 | map(callback: MapCallback): U[];
229 | map>(
230 | callback: MapCallback, U>,
231 | prop1: P1,
232 | ): U[];
233 | map>(
234 | callback: MapCallback, P2>, U>,
235 | prop1: P1,
236 | prop2: P2,
237 | ): U[];
238 | map<
239 | U,
240 | P1 extends keyof T,
241 | P2 extends IterProperties,
242 | P3 extends IterProperties>,
243 | >(
244 | callback: MapCallback, P2>, P3>, U>,
245 | prop1: P1,
246 | prop2: P2,
247 | prop3: P3,
248 | ): U[];
249 | map<
250 | U,
251 | P1 extends keyof T,
252 | P2 extends IterProperties,
253 | P3 extends IterProperties>,
254 | P4 extends IterProperties, P3>>,
255 | >(
256 | callback: MapCallback<
257 | IndexValue, P2>, P3>, P4>,
258 | U
259 | >,
260 | prop1: P1,
261 | prop2: P2,
262 | prop3: P3,
263 | prop4: P4,
264 | ): U[];
265 | map(
266 | callback: MapCallback,
267 | prop1: PropertyKey,
268 | prop2: PropertyKey,
269 | prop3: PropertyKey,
270 | prop4: PropertyKey,
271 | ...props: PropertyKey[]
272 | ): U[];
273 | }
274 |
275 | /** @deprecated `FastPath` was renamed to `AstPath` */
276 | export type FastPath = AstPath;
277 |
278 | export type BuiltInParser = (text: string, options?: any) => AST;
279 | export type BuiltInParserName =
280 | | "acorn"
281 | | "angular"
282 | | "babel-flow"
283 | | "babel-ts"
284 | | "babel"
285 | | "css"
286 | | "espree"
287 | | "flow"
288 | | "glimmer"
289 | | "graphql"
290 | | "html"
291 | | "json-stringify"
292 | | "json"
293 | | "json5"
294 | | "less"
295 | | "lwc"
296 | | "markdown"
297 | | "mdx"
298 | | "meriyah"
299 | | "scss"
300 | | "typescript"
301 | | "vue"
302 | | "yaml";
303 | export type BuiltInParsers = Record;
304 |
305 | export type CustomParser = (
306 | text: string,
307 | options: Options,
308 | ) => AST | Promise;
309 |
310 | /**
311 | * For use in `.prettierrc.js`, `.prettierrc.cjs`, `prettierrc.mjs`, `prettier.config.js`, `prettier.config.cjs`, `prettier.config.mjs`
312 | */
313 | export interface Config extends Options {
314 | overrides?: Array<{
315 | files: string | string[];
316 | excludeFiles?: string | string[];
317 | options?: Options;
318 | }>;
319 | }
320 |
321 | export interface Options extends Partial {}
322 |
323 | export interface RequiredOptions extends doc.printer.Options {
324 | /**
325 | * Print semicolons at the ends of statements.
326 | * @default true
327 | */
328 | semi: boolean;
329 | /**
330 | * Use single quotes instead of double quotes.
331 | * @default false
332 | */
333 | singleQuote: boolean;
334 | /**
335 | * Use single quotes in JSX.
336 | * @default false
337 | */
338 | jsxSingleQuote: boolean;
339 | /**
340 | * Print trailing commas wherever possible.
341 | * @default "all"
342 | */
343 | trailingComma: "none" | "es5" | "all";
344 | /**
345 | * Print spaces between brackets in object literals.
346 | * @default true
347 | */
348 | bracketSpacing: boolean;
349 | /**
350 | * Put the `>` of a multi-line HTML (HTML, JSX, Vue, Angular) element at the end of the last line instead of being
351 | * alone on the next line (does not apply to self closing elements).
352 | * @default false
353 | */
354 | bracketSameLine: boolean;
355 | /**
356 | * Put the `>` of a multi-line JSX element at the end of the last line instead of being alone on the next line.
357 | * @default false
358 | * @deprecated use bracketSameLine instead
359 | */
360 | jsxBracketSameLine: boolean;
361 | /**
362 | * Format only a segment of a file.
363 | * @default 0
364 | */
365 | rangeStart: number;
366 | /**
367 | * Format only a segment of a file.
368 | * @default Number.POSITIVE_INFINITY
369 | */
370 | rangeEnd: number;
371 | /**
372 | * Specify which parser to use.
373 | */
374 | parser: LiteralUnion | CustomParser;
375 | /**
376 | * Specify the input filepath. This will be used to do parser inference.
377 | */
378 | filepath: string;
379 | /**
380 | * Prettier can restrict itself to only format files that contain a special comment, called a pragma, at the top of the file.
381 | * This is very useful when gradually transitioning large, unformatted codebases to prettier.
382 | * @default false
383 | */
384 | requirePragma: boolean;
385 | /**
386 | * Prettier can insert a special @format marker at the top of files specifying that
387 | * the file has been formatted with prettier. This works well when used in tandem with
388 | * the --require-pragma option. If there is already a docblock at the top of
389 | * the file then this option will add a newline to it with the @format marker.
390 | * @default false
391 | */
392 | insertPragma: boolean;
393 | /**
394 | * By default, Prettier will wrap markdown text as-is since some services use a linebreak-sensitive renderer.
395 | * In some cases you may want to rely on editor/viewer soft wrapping instead, so this option allows you to opt out.
396 | * @default "preserve"
397 | */
398 | proseWrap: "always" | "never" | "preserve";
399 | /**
400 | * Include parentheses around a sole arrow function parameter.
401 | * @default "always"
402 | */
403 | arrowParens: "avoid" | "always";
404 | /**
405 | * Provide ability to support new languages to prettier.
406 | */
407 | plugins: Array;
408 | /**
409 | * How to handle whitespaces in HTML.
410 | * @default "css"
411 | */
412 | htmlWhitespaceSensitivity: "css" | "strict" | "ignore";
413 | /**
414 | * Which end of line characters to apply.
415 | * @default "lf"
416 | */
417 | endOfLine: "auto" | "lf" | "crlf" | "cr";
418 | /**
419 | * Change when properties in objects are quoted.
420 | * @default "as-needed"
421 | */
422 | quoteProps: "as-needed" | "consistent" | "preserve";
423 | /**
424 | * Whether or not to indent the code inside